/** * Diagnostic endpoints — used by the E2E probe script and the * Settings "run self-check" button. * * GET /api/v1/diag/counts * Returns current row counts across every V7 layer, so the user * can tell at a glance whether the capture / reward / induction * / crystallisation / abstraction pipelines have produced any * output yet. Handy after a scripted OpenClaw conversation when * you want to check "did the plugin even see my chat?" without * opening the DB. * * POST /api/v1/diag/simulate-turn * Accepts `{ sessionId, user, assistant, toolCalls?, feedback? }` * and pipes it through the core just like an `agent_end` event * would — open session, open episode, record a turn, close * episode. Used ONLY by the E2E probe script; gated behind * `?allow=1` so it can't accidentally fire from the viewer. */ import type { AgentKind, EpisodeId, SessionId, TurnInputDTO, TurnResultDTO, } from "../../agent-contract/dto.js"; import type { ServerDeps } from "../types.js"; import { parseJson, writeError, type Routes } from "./registry.js"; export function registerDiagRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/diag/counts", async () => { const core = deps.core; const [traces, episodes, policies, worldModels, skills, logs] = await Promise.all([ core.listTraces({ limit: 1, offset: 0 }), core.listEpisodeRows({ limit: 1, offset: 0, includeAllNamespaces: true }), core.listPolicies({ limit: 1, offset: 0, includeAllNamespaces: true }), core.listWorldModels({ limit: 1, offset: 0 }), core.listSkills({ limit: 1, includeAllNamespaces: true }), core.listApiLogs({ limit: 1, offset: 0 }), ]); // We use `listXxx` just to get *a* row — the actual per-layer // counts come from the `metrics()` call + per-layer API totals. const m = await core.metrics({ days: 365 }); return { traces: m.total, traces_today: m.writesToday, episodes: episodes.length > 0 ? await countEpisodes(core) : 0, policies: policies.length > 0 ? await countPolicies(core) : 0, worldModels: worldModels.length > 0 ? await countWorldModels(core) : 0, skills: skills.length > 0 ? await countSkills(core) : 0, apiLogs: logs.total, sessionsHave: m.sessions, embeddings: m.embeddings, }; }); routes.set("GET /api/v1/diag/namespace", async () => { const health = await deps.core.health(); const [traces, episodes, policies, worldModels, skills] = await Promise.all([ deps.core.listTraces({ limit: 200, offset: 0, includeAllNamespaces: true }), deps.core.listEpisodeRows({ limit: 200, offset: 0, includeAllNamespaces: true }), deps.core.listPolicies({ limit: 200, offset: 0, includeAllNamespaces: true }), deps.core.listWorldModels({ limit: 200, offset: 0 }), deps.core.listSkills({ limit: 200, includeAllNamespaces: true }), ]); const namespaces = new Map(); for (const row of [...traces, ...episodes, ...policies, ...worldModels, ...skills]) { const agentKind = row.ownerAgentKind ?? "unknown"; const profileId = row.ownerProfileId ?? "default"; const key = `${agentKind}/${profileId}`; const current = namespaces.get(key) ?? { agentKind, profileId, count: 0 }; current.count++; namespaces.set(key, current); } return { current: health.namespace ?? { agentKind: health.agent, profileId: "default" }, db: health.paths.db, namespaces: [...namespaces.values()].sort((a, b) => b.count - a.count), }; }); routes.set("POST /api/v1/diag/simulate-turn", async (ctx) => { // Safety lock: accept only when explicitly opted-in with // `?allow=1`. Without this header the endpoint returns 403 — we // don't want random web traffic driving synthetic turns into the // user's memory store. if (ctx.url.searchParams.get("allow") !== "1") { writeError(ctx, 403, "forbidden", "use ?allow=1 to enable this endpoint"); return; } const body = parseJson<{ agent?: AgentKind; sessionId?: SessionId; user: string; assistant: string; toolCalls?: Array<{ name: string; input?: unknown; output?: unknown; errorCode?: string }>; }>(ctx); if (!body.user || !body.assistant) { writeError(ctx, 400, "invalid_argument", "user and assistant are required"); return; } const agent: AgentKind = (body.agent as AgentKind) ?? "openclaw"; const sessionId = body.sessionId ?? ((await deps.core.openSession({ agent })) as SessionId); const ts = Date.now(); const turnIn: TurnInputDTO = { agent, sessionId, userText: body.user, ts, }; const packet = await deps.core.onTurnStart(turnIn); const episodeId = (packet.query.episodeId ?? null) as EpisodeId | null; if (!episodeId) { writeError(ctx, 500, "internal", "onTurnStart did not return an episodeId"); return; } const now = Date.now(); const turnOut: TurnResultDTO = { agent, sessionId, episodeId, agentText: body.assistant, toolCalls: (body.toolCalls ?? []).map((tc, i) => ({ id: `sim_${now}_${i}`, name: tc.name, input: tc.input ?? {}, output: tc.output ?? "", startedAt: now as never, endedAt: (now + 1) as never, errorCode: tc.errorCode, })), ts: now, }; const res = await deps.core.onTurnEnd(turnOut); return { ok: true, sessionId, episodeId, traceId: res.traceId, hits: packet.hits.length, }; }); } // Poor-man's counters — we walk batches of 500 until the list turns // up short. Safe for viewer-scale datasets; don't run on millions of // rows (use SQL directly in that case). async function countEpisodes(core: ServerDeps["core"]): Promise { return walkAll((limit, offset) => core.listEpisodeRows({ limit, offset, includeAllNamespaces: true }), ); } async function countPolicies(core: ServerDeps["core"]): Promise { return walkAll((limit, offset) => core.listPolicies({ limit, offset, includeAllNamespaces: true })); } async function countWorldModels(core: ServerDeps["core"]): Promise { return walkAll((limit, offset) => core.listWorldModels({ limit, offset }), ); } async function countSkills(core: ServerDeps["core"]): Promise { return walkAll((limit, offset) => core.listSkills({ limit, includeAllNamespaces: true })); void countSkills; // the offset is unused for listSkills; it returns // everything up to `limit` — fine for our cap. } async function walkAll( fetcher: (limit: number, offset: number) => Promise, ): Promise { const BATCH = 500; let offset = 0; let total = 0; for (let safety = 0; safety < 40; safety++) { const rows = await fetcher(BATCH, offset); total += rows.length; if (rows.length < BATCH) break; offset += BATCH; } return total; }