From 82a6a57330cde5d4894fb2cb109618ca66f5091e Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 25 Jun 2026 08:27:25 -0700 Subject: [PATCH] Doctor: expose session artifact findings (#95976) * feat(doctor): expose session artifact findings * fix(doctor): make session artifact findings advisory --- src/commands/doctor-session-snapshots.test.ts | 98 +++++++++++++++++-- src/commands/doctor-session-snapshots.ts | 73 ++++++++++++++ .../doctor-session-transcripts.test.ts | 41 ++++++++ src/commands/doctor-session-transcripts.ts | 72 ++++++++++++-- src/flows/doctor-health-contributions.test.ts | 2 + src/flows/doctor-health-contributions.ts | 60 ++++++++++++ 6 files changed, 330 insertions(+), 16 deletions(-) diff --git a/src/commands/doctor-session-snapshots.test.ts b/src/commands/doctor-session-snapshots.test.ts index 957db75fa23..641f83e426d 100644 --- a/src/commands/doctor-session-snapshots.test.ts +++ b/src/commands/doctor-session-snapshots.test.ts @@ -15,8 +15,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ })); import { + detectSessionSnapshotHealthIssues, noteSessionSnapshotHealth, scanSessionStoreForStaleRuntimeSnapshotPaths, + sessionSnapshotIssueToHealthFinding, + sessionSnapshotIssueToRepairEffect, } from "./doctor-session-snapshots.js"; function sessionEntry(patch: Partial): SessionEntry { @@ -66,6 +69,23 @@ async function writeSessionStore( await fs.writeFile(storePath, JSON.stringify(store, null, 2)); } +function readMainSessionEntry(raw: string): SessionEntry { + const parsed = JSON.parse(raw) as Record; + const entry = parsed["agent:main"]; + if (!entry) { + throw new Error("expected agent:main session entry"); + } + return entry; +} + +function readMainSkillsSnapshot(raw: string): NonNullable { + const snapshot = readMainSessionEntry(raw).skillsSnapshot; + if (!snapshot) { + throw new Error("expected agent:main skills snapshot"); + } + return snapshot; +} + describe("doctor session snapshot stale runtime metadata", () => { let root = ""; let bundledSkillsDir = ""; @@ -135,6 +155,57 @@ describe("doctor session snapshot stale runtime metadata", () => { ]); }); + it("maps stale snapshot paths to structured findings and dry-run effects", async () => { + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); + const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); + await writeSessionStore(storePath, { + "agent:main": sessionEntry({ + skillsSnapshot: { + prompt: skillPrompt(stalePath), + skills: [{ name: "doctor" }], + }, + }), + }); + + const [issue] = await detectSessionSnapshotHealthIssues({ + storePaths: [storePath], + bundledSkillsDir, + }); + + if (!issue) { + throw new Error("expected session snapshot health issue"); + } + expect(issue).toMatchObject({ + storePath, + sessionKey: "agent:main", + field: "skillsSnapshot.prompt", + cachedPath: stalePath, + expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"), + }); + expect(sessionSnapshotIssueToHealthFinding(issue)).toMatchObject({ + checkId: "core/doctor/session-snapshots", + severity: "info", + path: storePath, + target: stalePath, + requirement: expect.stringContaining(bundledSkillsDir), + fixHint: expect.stringContaining("openclaw doctor --fix"), + }); + expect(sessionSnapshotIssueToRepairEffect(issue)).toEqual({ + kind: "file", + action: "would-rewrite-session-snapshot-path", + target: storePath, + dryRunSafe: false, + }); + }); + it("expands home-relative cached bundled skill locations before classifying them", () => { const homeDir = path.join(root, "home"); const stalePath = "~/old-runtime/node_modules/openclaw/skills/doctor/SKILL.md"; @@ -456,8 +527,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); const raw = await fs.readFile(storePath, "utf-8"); - expect(raw).not.toContain(stalePath); - expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md")); + const snapshot = readMainSkillsSnapshot(raw); + expect(snapshot.prompt).not.toContain(stalePath); + expect(snapshot.prompt).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md")); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); @@ -535,9 +607,13 @@ describe("doctor session snapshot repair (shouldRepair)", () => { const raw = await fs.readFile(storePath, "utf-8"); const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md")); - expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md")); - expect(raw).toContain(expectedBaseDir); - expect(raw).not.toContain(path.join(root, "old-runtime")); + const expectedPath = path.join(bundledSkillsDir, "doctor", "SKILL.md"); + const snapshot = readMainSkillsSnapshot(raw); + const skill = snapshot.resolvedSkills?.[0]; + expect(skill?.filePath).toBe(expectedPath); + expect(skill?.baseDir).toBe(expectedBaseDir); + expect(skill?.sourceInfo.path).toBe(expectedPath); + expect(skill?.sourceInfo.baseDir).toBe(expectedBaseDir); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); @@ -576,9 +652,12 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); const raw = await fs.readFile(storePath, "utf-8"); - expect(raw).toContain(currentPath); - expect(raw).toContain(path.dirname(currentPath)); - expect(raw).not.toContain(path.join(root, "old-runtime")); + const snapshot = readMainSkillsSnapshot(raw); + const repairedSkill = snapshot.resolvedSkills?.[0]; + expect(repairedSkill?.filePath).toBe(currentPath); + expect(repairedSkill?.baseDir).toBe(path.dirname(currentPath)); + expect(repairedSkill?.sourceInfo.path).toBe(currentPath); + expect(repairedSkill?.sourceInfo.baseDir).toBe(path.dirname(currentPath)); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); @@ -743,7 +822,8 @@ describe("doctor session snapshot repair (shouldRepair)", () => { expect(backupFiles.length).toBe(1); const backupContent = await fs.readFile(path.join(dir, backupFiles[0]), "utf-8"); - expect(backupContent).toContain(stalePath); + const backupSnapshot = readMainSkillsSnapshot(backupContent); + expect(backupSnapshot.prompt).toContain(stalePath); }); it("is idempotent — second repair finds nothing", async () => { diff --git a/src/commands/doctor-session-snapshots.ts b/src/commands/doctor-session-snapshots.ts index 3137ca9e2a4..53cfe0e2bef 100644 --- a/src/commands/doctor-session-snapshots.ts +++ b/src/commands/doctor-session-snapshots.ts @@ -12,11 +12,14 @@ import { import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { expandHomePrefix } from "../infra/home-dir.js"; import { writeTextAtomic } from "../infra/json-files.js"; import { resolveBundledSkillsDir } from "../skills/loading/bundled-dir.js"; import { shortenHomePath } from "../utils.js"; +const SESSION_SNAPSHOTS_CHECK_ID = "core/doctor/session-snapshots"; + type SnapshotPathSource = | "skillsSnapshot.prompt" | "skillsSnapshot.resolvedSkills" @@ -34,6 +37,10 @@ type StaleSessionSnapshotPathFinding = { expectedPath: string; }; +export type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & { + storePath: string; +}; + function decodeXmlText(value: string): string { return value .replace(/</g, "<") @@ -286,6 +293,72 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record { + const bundledSkillsDir = params?.bundledSkillsDir ?? resolveBundledSkillsDir(); + if (!bundledSkillsDir) { + return []; + } + const storePaths = + params?.storePaths ?? + resolveSessionStorePaths({ cfg: params?.cfg, env: params?.env }) ?? + (await listSessionStorePaths(resolveStateDir(params?.env))); + const issues: SessionSnapshotHealthIssue[] = []; + for (const storePath of storePaths) { + let store: Record; + try { + store = loadSessionStoreForSnapshotScan(storePath); + } catch { + continue; + } + const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({ + store, + bundledSkillsDir, + env: params?.env, + }); + for (const finding of findings) { + issues.push({ + sessionKey: finding.sessionKey, + field: finding.field, + cachedPath: finding.cachedPath, + expectedPath: finding.expectedPath, + storePath, + }); + } + } + return issues; +} + +export function sessionSnapshotIssueToHealthFinding( + issue: SessionSnapshotHealthIssue, +): HealthFinding { + return { + checkId: SESSION_SNAPSHOTS_CHECK_ID, + severity: "info", + message: `${issue.sessionKey} cached session metadata references an inactive runtime root that can be cleaned up.`, + path: issue.storePath, + target: issue.cachedPath, + requirement: `Current bundled skill path: ${issue.expectedPath}`, + fixHint: + "To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite stale cached session metadata paths, or start a fresh session after confirming history can be retired.", + }; +} + +export function sessionSnapshotIssueToRepairEffect( + issue: SessionSnapshotHealthIssue, +): HealthRepairEffect { + return { + kind: "file", + action: "would-rewrite-session-snapshot-path", + target: issue.storePath, + dryRunSafe: false, + }; +} + /** Replaces stale paths in raw, JSON-escaped, and XML-escaped prompt text. */ function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPathFinding): string { const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1); diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index 0e2d0f3aab0..6e0e7f40322 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -12,8 +12,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ })); import { + detectSessionTranscriptHealthIssues, noteSessionTranscriptHealth, repairBrokenSessionTranscriptFile, + sessionTranscriptIssueToHealthFinding, + sessionTranscriptIssueToRepairEffect, } from "./doctor-session-transcripts.js"; function countNonEmptyLines(value: string): number { @@ -150,6 +153,44 @@ describe("doctor session transcript repair", () => { expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3); }); + it("maps affected transcripts to structured findings and dry-run effects", async () => { + const filePath = await writeTranscript([ + { type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" }, + { + type: "message", + id: "legacy-assistant", + parentId: null, + message: { + role: "assistant", + provider: "openai-codex", + api: "openai-codex-responses", + content: [{ type: "text", text: "hello" }], + }, + }, + ]); + const sessionsDir = path.dirname(filePath); + + const [issue] = await detectSessionTranscriptHealthIssues({ sessionDirs: [sessionsDir] }); + + if (!issue) { + throw new Error("expected session transcript health issue"); + } + expect(issue?.filePath).toBe(filePath); + expect(sessionTranscriptIssueToHealthFinding(issue)).toMatchObject({ + checkId: "core/doctor/session-transcripts", + severity: "info", + path: filePath, + fixHint: expect.stringContaining("openclaw doctor --fix"), + }); + expect(sessionTranscriptIssueToRepairEffect(issue)).toEqual({ + kind: "file", + action: "would-rewrite-session-transcript", + target: filePath, + dryRunSafe: false, + }); + expect(await fs.readFile(filePath, "utf-8")).toContain("openai-codex"); + }); + it("repairs supported current-version linear transcripts", async () => { const filePath = await writeTranscript([ { type: "session", version: 3, id: "session-linear", timestamp: "2026-06-15T00:00:00Z" }, diff --git a/src/commands/doctor-session-transcripts.ts b/src/commands/doctor-session-transcripts.ts index 9ae33f4a35c..8ae36e28acc 100644 --- a/src/commands/doctor-session-transcripts.ts +++ b/src/commands/doctor-session-transcripts.ts @@ -16,8 +16,11 @@ import { scanSessionTranscriptTree, selectSessionTranscriptTreePathNodes, } from "../config/sessions/transcript-tree.js"; +import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { shortenHomePath } from "../utils.js"; +const SESSION_TRANSCRIPTS_CHECK_ID = "core/doctor/session-transcripts"; + type TranscriptEntry = Record & { id?: unknown; parentId?: unknown; @@ -36,6 +39,10 @@ type TranscriptRepairResult = { reason?: string; }; +export type SessionTranscriptHealthIssue = TranscriptRepairResult & { + broken: true; +}; + type ActiveTranscriptPath = { entries: TranscriptEntry[]; entriesToPersist: TranscriptEntry[]; @@ -372,6 +379,57 @@ async function listSessionTranscriptFiles(sessionDirs: string[]): Promise a.localeCompare(b)); } +export async function detectSessionTranscriptHealthIssues(params?: { + sessionDirs?: string[]; +}): Promise { + let sessionDirs = params?.sessionDirs; + try { + sessionDirs ??= await resolveAgentSessionDirs(resolveStateDir(process.env)); + } catch { + return []; + } + + const files = await listSessionTranscriptFiles(sessionDirs); + const issues: SessionTranscriptHealthIssue[] = []; + for (const filePath of files) { + const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: false }); + if (result.broken) { + issues.push(result as SessionTranscriptHealthIssue); + } + } + return issues; +} + +export function sessionTranscriptIssueToHealthFinding( + issue: SessionTranscriptHealthIssue, +): HealthFinding { + const metadata = + issue.legacyOpenAICodexEntries > 0 + ? ` ${issue.legacyOpenAICodexEntries} legacy OpenAI Codex metadata entr${ + issue.legacyOpenAICodexEntries === 1 ? "y" : "ies" + }` + : ""; + return { + checkId: SESSION_TRANSCRIPTS_CHECK_ID, + severity: "info", + message: `Session transcript has legacy branch or provider metadata that can be cleaned up.${metadata}`, + path: issue.filePath, + fixHint: + "To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite affected transcripts to their active branch.", + }; +} + +export function sessionTranscriptIssueToRepairEffect( + issue: SessionTranscriptHealthIssue, +): HealthRepairEffect { + return { + kind: "file", + action: "would-rewrite-session-transcript", + target: issue.filePath, + dryRunSafe: false, + }; +} + /** Scans session transcript files and reports or repairs legacy/broken transcript state. */ export async function noteSessionTranscriptHealth(params?: { shouldRepair?: boolean; @@ -386,14 +444,14 @@ export async function noteSessionTranscriptHealth(params?: { return; } - const files = await listSessionTranscriptFiles(sessionDirs); - if (files.length === 0) { - return; - } - const results: TranscriptRepairResult[] = []; - for (const filePath of files) { - results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair })); + if (shouldRepair) { + const files = await listSessionTranscriptFiles(sessionDirs); + for (const filePath of files) { + results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair })); + } + } else { + results.push(...(await detectSessionTranscriptHealthIssues({ sessionDirs }))); } const broken = results.filter((result) => result.broken); if (broken.length === 0) { diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index f61a26ff113..ec16e918f66 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -1014,6 +1014,8 @@ describe("doctor health contributions", () => { expect(contributionIds).toContain("core/doctor/sandbox/registry-files"); expect(contributionIds).toContain("core/doctor/gateway-services/extra"); expect(contributionIds).toContain("core/doctor/config-audit-scrub"); + expect(contributionIds).toContain("core/doctor/session-transcripts"); + expect(contributionIds).toContain("core/doctor/session-snapshots"); expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); }); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 6e2543bfaef..55efcc82931 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1298,11 +1298,71 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:session-transcripts", label: "Session transcripts", + healthChecks: { + id: "core/doctor/session-transcripts", + description: "Legacy or branchy session transcript files are represented as findings.", + async detect() { + const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToHealthFinding } = + await import("../commands/doctor-session-transcripts.js"); + return (await detectSessionTranscriptHealthIssues()).map( + sessionTranscriptIssueToHealthFinding, + ); + }, + async repair(ctx) { + const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToRepairEffect } = + await import("../commands/doctor-session-transcripts.js"); + const effects = (await detectSessionTranscriptHealthIssues()).map( + sessionTranscriptIssueToRepairEffect, + ); + if (ctx.dryRun === true) { + return { status: "repaired", changes: [], effects }; + } + return { + status: "skipped", + reason: "legacy doctor session transcript contribution owns transcript rewrites", + changes: [], + effects, + }; + }, + }, run: runSessionTranscriptsHealth, }), createDoctorHealthContribution({ id: "doctor:session-snapshots", label: "Session snapshots", + healthChecks: { + id: "core/doctor/session-snapshots", + description: "Stale cached session snapshot paths are represented as findings.", + async detect(ctx) { + const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToHealthFinding } = + await import("../commands/doctor-session-snapshots.js"); + return ( + await detectSessionSnapshotHealthIssues({ + cfg: ctx.cfg, + env: process.env, + }) + ).map(sessionSnapshotIssueToHealthFinding); + }, + async repair(ctx) { + const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToRepairEffect } = + await import("../commands/doctor-session-snapshots.js"); + const effects = ( + await detectSessionSnapshotHealthIssues({ + cfg: ctx.cfg, + env: process.env, + }) + ).map(sessionSnapshotIssueToRepairEffect); + if (ctx.dryRun === true) { + return { status: "repaired", changes: [], effects }; + } + return { + status: "skipped", + reason: "legacy doctor session snapshot contribution owns snapshot rewrites", + changes: [], + effects, + }; + }, + }, run: runSessionSnapshotsHealth, }), createDoctorHealthContribution({