/** * Policies ("经验") and World Models ("世界环境知识") REST routes. * * Both panels live on the same conceptual rung as Memories: a * newest-first listing with search, single-row GET, status toggles, * and hard delete. The backing data comes from * `handle.repos.policies` / `handle.repos.worldModel`, surfaced on * `MemoryCore` via: * * - `listPolicies({ status?, limit, offset, q })` * - `setPolicyStatus(id, status)` // candidate → active → archived * - `deletePolicy(id)` * - `listWorldModels({ limit, offset, q })` * - `deleteWorldModel(id)` * - `getPolicy(id)` / `getWorldModel(id)` (pre-existing) * * The viewer's **PoliciesView** and **WorldModelsView** call * exclusively through this file. We don't talk to the repos directly * from the HTTP layer — that'd skip the `ensureLive` / RLS guards * the core applies for us. */ import type { PolicyDTO } from "../../agent-contract/dto.js"; import type { ServerDeps } from "../types.js"; import { parseJson, writeError, type Routes } from "./registry.js"; export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { // ─── Policies (L2 "经验") ──────────────────────────────────────── routes.set("GET /api/v1/policies", async (ctx) => { const params = ctx.url.searchParams; const parsedLimit = Number(params.get("limit")); const parsedOffset = Number(params.get("offset")); const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50; const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const statusRaw = params.get("status"); const status = isValidPolicyStatus(statusRaw) ? statusRaw : undefined; const q = params.get("q") || undefined; const ownerAgentKind = params.get("ownerAgentKind") || undefined; const ownerProfileId = params.get("ownerProfileId") || undefined; const policies = await deps.core.listPolicies({ status, limit, offset, q, ownerAgentKind, ownerProfileId, }); const total = await deps.core.countPolicies({ status, q, ownerAgentKind, ownerProfileId, }); return { policies, limit, offset, total, nextOffset: policies.length === limit ? offset + limit : undefined, }; }); routes.setPattern("GET /api/v1/policies/:id", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const policy = await deps.core.getPolicy(id); if (!policy) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } return policy; }); /** * PATCH /api/v1/policies/:id — transition status and/or edit body * fields. Body shape (all optional, at least one required): * * { * status?: 'candidate' | 'active' | 'archived', * title?, trigger?, procedure?, verification?, boundary?: string * } * * Pre-existing callers that only sent `{status}` keep working — the * viewer's "启用 / 停用 / 归档" buttons still hit this endpoint with * just the status flag. */ routes.setPattern("PATCH /api/v1/policies/:id", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const body = parseJson<{ status?: unknown; title?: unknown; trigger?: unknown; procedure?: unknown; verification?: unknown; boundary?: unknown; }>(ctx); const contentPatch: { title?: string; trigger?: string; procedure?: string; verification?: string; boundary?: string; } = {}; if (typeof body.title === "string") contentPatch.title = body.title; if (typeof body.trigger === "string") contentPatch.trigger = body.trigger; if (typeof body.procedure === "string") contentPatch.procedure = body.procedure; if (typeof body.verification === "string") contentPatch.verification = body.verification; if (typeof body.boundary === "string") contentPatch.boundary = body.boundary; const hasContent = Object.keys(contentPatch).length > 0; const hasStatus = body.status !== undefined; if (!hasStatus && !hasContent) { writeError( ctx, 400, "invalid_argument", "provide at least one of status / title / trigger / procedure / verification / boundary", ); return; } if (hasStatus && !isValidPolicyStatus(body.status)) { writeError( ctx, 400, "invalid_argument", "status must be one of: candidate, active, archived", ); return; } let updated = hasContent ? await deps.core.updatePolicy(id, contentPatch) : await deps.core.getPolicy(id); if (!updated) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } if (hasStatus) { updated = await deps.core.setPolicyStatus( id, body.status as PolicyDTO["status"], ); if (!updated) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } } return updated; }); /** * `POST /api/v1/policies/:id/share` — set/clear the share state. * Same body shape as `POST /api/v1/traces/:id/share`. */ routes.setPattern("POST /api/v1/policies/:id/share", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const body = parseJson<{ scope?: "private" | "public" | "hub" | null; target?: string | null; }>(ctx); const scope = body.scope === undefined ? "public" : body.scope; const updated = await deps.core.sharePolicy(id, { scope: scope ?? null, target: body.target ?? null, sharedAt: scope ? Date.now() : null, }); if (!updated) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } return updated; }); routes.setPattern("DELETE /api/v1/policies/:id", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } return await deps.core.deletePolicy(id); }); /** * `POST /api/v1/policies/:id/guidance` — append decision guidance to * a policy. Body: `{ preference?: string[]; antiPattern?: string[] }`. * The viewer's PolicyDrawer uses this for manual guidance entry * when the feedback pipeline hasn't produced any yet. Duplicates * are de-duped server-side. */ routes.setPattern("POST /api/v1/policies/:id/guidance", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const body = parseJson<{ preference?: unknown; antiPattern?: unknown }>(ctx); const pref = normalizeGuidanceList(body.preference); const avoid = normalizeGuidanceList(body.antiPattern); if (pref.length === 0 && avoid.length === 0) { writeError( ctx, 400, "invalid_argument", "provide at least one preference or antiPattern string", ); return; } const updated = await deps.core.editPolicyGuidance(id, { preference: pref, antiPattern: avoid, }); if (!updated) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } return updated; }); /** * `GET /api/v1/policies/:id/usage` — everything that references this * policy from the other tiers. The viewer's PolicyDrawer calls this * to render the "Related skills / In world models / Source episodes" * cross-link sections. Kept server-side so the drawer doesn't have * to pull every skill + world-model to filter client-side. */ routes.setPattern("GET /api/v1/policies/:id/usage", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const policy = await deps.core.getPolicy(id); if (!policy) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } const [skills, worldModels] = await Promise.all([ deps.core.listSkills({ limit: 500 }), deps.core.listWorldModels({ limit: 500 }), ]); return { skills: skills .filter((s) => s.sourcePolicyIds.includes(id)) .map((s) => ({ id: s.id, name: s.name, status: s.status, eta: s.eta })), worldModels: worldModels .filter((w) => w.policyIds.includes(id)) .map((w) => ({ id: w.id, title: w.title })), // Policy rows carry their source episodes directly — no join // needed. Return the full list so the drawer can render every // task that contributed a supporting trace. sourceEpisodes: policy.sourceEpisodeIds ?? [], }; }); // ─── World models (L3 "世界环境知识") ──────────────────────────── routes.set("GET /api/v1/world-models", async (ctx) => { const params = ctx.url.searchParams; const parsedLimit = Number(params.get("limit")); const parsedOffset = Number(params.get("offset")); const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50; const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const q = params.get("q") || undefined; const ownerAgentKind = params.get("ownerAgentKind") || undefined; const ownerProfileId = params.get("ownerProfileId") || undefined; const worldModels = await deps.core.listWorldModels({ limit, offset, q, ownerAgentKind, ownerProfileId, }); const total = await deps.core.countWorldModels({ q, ownerAgentKind, ownerProfileId, }); return { worldModels, limit, offset, total, nextOffset: worldModels.length === limit ? offset + limit : undefined, }; }); routes.setPattern("GET /api/v1/world-models/:id", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const model = await deps.core.getWorldModel(id); if (!model) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } return model; }); /** * `GET /api/v1/world-models/:id/usage` — cross-link payload for the * world-model drawer. Resolves `policyIds` to {id, title, status} * triples so the drawer can render clickable policy chips without * another round-trip per chip. */ routes.setPattern("GET /api/v1/world-models/:id/usage", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const wm = await deps.core.getWorldModel(id); if (!wm) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } const policies = await Promise.all( wm.policyIds.map(async (pid) => { const p = await deps.core.getPolicy(pid); return p ? { id: p.id, title: p.title, status: p.status, gain: p.gain } : { id: pid, title: null, status: null, gain: null }; }), ); return { policies }; }); routes.setPattern("DELETE /api/v1/world-models/:id", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } return await deps.core.deleteWorldModel(id); }); /** * PATCH /api/v1/world-models/:id — edit body and/or flip lifecycle. * Body shape (all optional, at least one required): * * { title?: string; body?: string; status?: 'active' | 'archived' } */ routes.setPattern("PATCH /api/v1/world-models/:id", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const body = parseJson<{ title?: unknown; body?: unknown; status?: unknown; }>(ctx); const patch: { title?: string; body?: string; status?: "active" | "archived" } = {}; if (typeof body.title === "string") patch.title = body.title; if (typeof body.body === "string") patch.body = body.body; if (body.status === "active" || body.status === "archived") { patch.status = body.status; } if (Object.keys(patch).length === 0) { writeError( ctx, 400, "invalid_argument", "provide at least one of title / body / status", ); return; } const updated = await deps.core.updateWorldModel(id, patch); if (!updated) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } return updated; }); /** * `POST /api/v1/world-models/:id/share` — set/clear the share * state. Same body shape as `POST /api/v1/traces/:id/share`. */ routes.setPattern("POST /api/v1/world-models/:id/share", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const body = parseJson<{ scope?: "private" | "public" | "hub" | null; target?: string | null; }>(ctx); const scope = body.scope === undefined ? "public" : body.scope; const updated = await deps.core.shareWorldModel(id, { scope: scope ?? null, target: body.target ?? null, sharedAt: scope ? Date.now() : null, }); if (!updated) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } return updated; }); /** * Soft-archive a world model (reversible). Mirrors the trace-level * "归档" affordance. */ routes.setPattern("POST /api/v1/world-models/:id/archive", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const updated = await deps.core.archiveWorldModel(id); if (!updated) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } return updated; }); routes.setPattern("POST /api/v1/world-models/:id/unarchive", async (ctx) => { const id = ctx.params.id; if (!id) { writeError(ctx, 400, "invalid_argument", "id is required"); return; } const updated = await deps.core.unarchiveWorldModel(id); if (!updated) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } return updated; }); } function isValidPolicyStatus(v: unknown): v is PolicyDTO["status"] { return v === "candidate" || v === "active" || v === "archived"; } function normalizeGuidanceList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const out: string[] = []; for (const item of raw) { if (typeof item !== "string") continue; const t = item.trim(); if (!t) continue; // Cap line length so a malformed client doesn't flood boundary. out.push(t.length > 400 ? t.slice(0, 400) : t); if (out.length >= 20) break; } return out; }