#!/usr/bin/env -S node --loader tsx /** * admin persist audit harness. * * Compares JSONL canonical state against Neo4j projection for a given * sessionId. Prints one [admin-persist-audit] divergence line per * (sdkTurnUuid, expected) gap; non-zero exit on any mismatch. Designed to * run against an operator-supplied stream log fixture WITHOUT re-executing * the live session — JSONL is the only ground truth this script consults. * * Usage: * tsx platform/scripts/admin-persist-audit.ts \ * --conversation-id= \ * --account-id= \ * --jsonl= # optional override; otherwise resolved from accountId+sessionId * --session-id= # required if --jsonl not provided * * Exit codes: * 0 = no divergences * 1 = at least one Message or Component absent from Neo4j * 2 = invocation error (missing args, file unreadable, Neo4j unreachable) * * Why audit-only and not also auto-heal: the heal-on-resume writer at * server/routes/admin/sessions.ts handles the live path. This harness exists * for forensic investigation against operator-supplied JSONLs (e.g. the * 2026-05-07 stream log that motivated this task) where the live session * has long since terminated. */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; import process from "node:process"; import { replayJsonl, resolveJsonlPath } from "../ui/app/lib/claude-agent/jsonl-replay"; import { ACCOUNTS_DIR } from "../ui/app/lib/claude-agent/account"; import { getRecentMessages, getSession } from "../ui/app/lib/neo4j-store"; import { PERSISTENT_COMPONENTS } from "../lib/persistent-components/src/index"; // `AuditArgs` and `runAudit` are exported so the test // (`platform/scripts/__tests__/admin-persist-audit.test.ts`) can mock the // Neo4j surface and drive the audit with synthetic JSONL + Neo4j seeds, // verifying the divergence-line shape and non-zero exit code without // re-executing the live session or requiring a real graph connection. export interface AuditArgs { sessionId: string; accountId: string; jsonlPath: string; } function parseArgs(argv: string[]): AuditArgs | { error: string } { const out: Partial = {}; let sessionId: string | undefined; let jsonlOverride: string | undefined; for (const a of argv) { const m = a.match(/^--([a-z-]+)=(.+)$/); if (!m) continue; const [, key, val] = m; if (key === "conversation-id") out.sessionId = val; else if (key === "account-id") out.accountId = val; else if (key === "session-id") sessionId = val; else if (key === "jsonl") jsonlOverride = val; } if (!out.sessionId) return { error: "--conversation-id required" }; if (!out.accountId) return { error: "--account-id required" }; if (jsonlOverride) { out.jsonlPath = resolve(jsonlOverride); } else if (sessionId) { const accountDir = resolve(ACCOUNTS_DIR, out.accountId); out.jsonlPath = resolveJsonlPath(homedir(), accountDir, sessionId); } else { return { error: "either --jsonl or --session-id required" }; } return out as AuditArgs; } export async function runAudit(args: AuditArgs): Promise { const { sessionId, jsonlPath } = args; if (!existsSync(jsonlPath)) { console.error(`[admin-persist-audit] jsonl absent path=${jsonlPath} convId=${sessionId.slice(0, 8)}`); return 2; } // JSONL replay — derives the canonical message stream and the expected // component side-effects. const replay = replayJsonl(jsonlPath); if (replay.malformedLines > 0) { console.error(`[admin-persist-audit] jsonl-malformed-lines convId=${sessionId.slice(0, 8)} count=${replay.malformedLines}`); } // Neo4j projection — what the resume route would have rendered pre-940. let neo4j: Awaited>; try { neo4j = await getRecentMessages(sessionId, 1000); } catch (err) { console.error(`[admin-persist-audit] neo4j-read-failed convId=${sessionId.slice(0, 8)} reason=${err instanceof Error ? err.message : String(err)}`); return 2; } // Build a set of (role, content) keys present in Neo4j for fast lookup. // Same matching key the resume route uses, for parity. const neo4jByKey = new Map(); for (const n of neo4j) neo4jByKey.set(`${n.role}\x1f${n.content}`, n); let divergences = 0; for (const j of replay.messages) { const key = `${j.role}\x1f${j.content}`; const match = neo4jByKey.get(key); if (!match) { // Whole message absent from Neo4j. console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=message missing reason=neo4j-row-absent`); divergences += 1; // Each missing message implies its components are also missing — emit // one divergence line per absent component for forensic completeness. for (const c of j.components) { console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`); divergences += 1; } continue; } // Message exists; cross-check component count (Neo4j carries components // as siblings of :Message via :HAS_COMPONENT). const neoComps = match.components ?? []; if (neoComps.length < j.components.length) { for (let i = neoComps.length; i < j.components.length; i++) { const c = j.components[i]; console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`); divergences += 1; } } } // Every PERSISTENT_COMPONENTS:Component row must have a // sibling :KnowledgeDocument with a matching attachmentId. A missing // sibling means the disk-write failed mid-render and the writer's // :KnowledgeDocument MERGE didn't fire. Surfaced as `kd-row-absent`. const projectionSession = getSession(); try { const componentRowsResult = await projectionSession.run( `MATCH (m:Message {sessionId: $sessionId})-[:HAS_COMPONENT]->(c:Component) WHERE c.name IN $names OPTIONAL MATCH (k:KnowledgeDocument {accountId: c.accountId, attachmentId: c.attachmentId}) WHERE c.attachmentId IS NOT NULL RETURN c.componentId AS componentId, c.name AS componentName, c.accountId AS accountId, c.attachmentId AS attachmentId, k IS NOT NULL AS hasProjection`, { sessionId, names: Array.from(PERSISTENT_COMPONENTS) }, ); for (const record of componentRowsResult.records) { const componentId = record.get("componentId") as string; const componentName = record.get("componentName") as string; const attachmentId = record.get("attachmentId") as string | null; const hasProjection = record.get("hasProjection") === true; if (!attachmentId) { console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=empty-attachment-id`); divergences += 1; continue; } if (!hasProjection) { console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=kd-row-absent attachmentId=${attachmentId.slice(0, 8)}`); divergences += 1; } } } finally { await projectionSession.close(); } if (divergences === 0) { console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=0 status=ok`); return 0; } console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=${divergences} status=mismatch`); return 1; } async function main(): Promise { const parsed = parseArgs(process.argv.slice(2)); if ("error" in parsed) { console.error(`[admin-persist-audit] usage error: ${parsed.error}`); return 2; } return runAudit(parsed); } // only invoke main() when this script is executed directly. // Importing the module (e.g. from `admin-persist-audit.test.ts`) reads // `runAudit` and `AuditArgs` without firing the top-level argv parser or // process.exit, which would otherwise kill the vitest worker before any // assertion ran. `import.meta.url` resolves to the script path under tsx; // the suffix check matches both the .ts source and any compiled .js output. const invokedDirectly = (() => { try { const entry = process.argv[1]; return typeof entry === "string" && import.meta.url.endsWith(entry.replace(/\\/g, "/").split("/").pop() ?? ""); } catch { return false; } })(); if (invokedDirectly) { main() .then((code) => process.exit(code)) .catch((err) => { console.error(`[admin-persist-audit] crashed: ${err instanceof Error ? err.stack : String(err)}`); process.exit(2); }); }