Skip to content

Commit 82a6a57

Browse files
authored
Doctor: expose session artifact findings (#95976)
* feat(doctor): expose session artifact findings * fix(doctor): make session artifact findings advisory
1 parent 01ce03c commit 82a6a57

6 files changed

Lines changed: 330 additions & 16 deletions

src/commands/doctor-session-snapshots.test.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
1515
}));
1616

1717
import {
18+
detectSessionSnapshotHealthIssues,
1819
noteSessionSnapshotHealth,
1920
scanSessionStoreForStaleRuntimeSnapshotPaths,
21+
sessionSnapshotIssueToHealthFinding,
22+
sessionSnapshotIssueToRepairEffect,
2023
} from "./doctor-session-snapshots.js";
2124

2225
function sessionEntry(patch: Partial<SessionEntry>): SessionEntry {
@@ -66,6 +69,23 @@ async function writeSessionStore(
6669
await fs.writeFile(storePath, JSON.stringify(store, null, 2));
6770
}
6871

72+
function readMainSessionEntry(raw: string): SessionEntry {
73+
const parsed = JSON.parse(raw) as Record<string, SessionEntry>;
74+
const entry = parsed["agent:main"];
75+
if (!entry) {
76+
throw new Error("expected agent:main session entry");
77+
}
78+
return entry;
79+
}
80+
81+
function readMainSkillsSnapshot(raw: string): NonNullable<SessionEntry["skillsSnapshot"]> {
82+
const snapshot = readMainSessionEntry(raw).skillsSnapshot;
83+
if (!snapshot) {
84+
throw new Error("expected agent:main skills snapshot");
85+
}
86+
return snapshot;
87+
}
88+
6989
describe("doctor session snapshot stale runtime metadata", () => {
7090
let root = "";
7191
let bundledSkillsDir = "";
@@ -135,6 +155,57 @@ describe("doctor session snapshot stale runtime metadata", () => {
135155
]);
136156
});
137157

158+
it("maps stale snapshot paths to structured findings and dry-run effects", async () => {
159+
const stalePath = path.join(
160+
root,
161+
"old-runtime",
162+
"node_modules",
163+
"openclaw",
164+
"skills",
165+
"doctor",
166+
"SKILL.md",
167+
);
168+
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
169+
await writeSessionStore(storePath, {
170+
"agent:main": sessionEntry({
171+
skillsSnapshot: {
172+
prompt: skillPrompt(stalePath),
173+
skills: [{ name: "doctor" }],
174+
},
175+
}),
176+
});
177+
178+
const [issue] = await detectSessionSnapshotHealthIssues({
179+
storePaths: [storePath],
180+
bundledSkillsDir,
181+
});
182+
183+
if (!issue) {
184+
throw new Error("expected session snapshot health issue");
185+
}
186+
expect(issue).toMatchObject({
187+
storePath,
188+
sessionKey: "agent:main",
189+
field: "skillsSnapshot.prompt",
190+
cachedPath: stalePath,
191+
expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"),
192+
});
193+
expect(sessionSnapshotIssueToHealthFinding(issue)).toMatchObject({
194+
checkId: "core/doctor/session-snapshots",
195+
severity: "info",
196+
path: storePath,
197+
target: stalePath,
198+
requirement: expect.stringContaining(bundledSkillsDir),
199+
fixHint: expect.stringContaining("openclaw doctor --fix"),
200+
});
201+
expect(sessionSnapshotIssueToRepairEffect(issue)).toEqual({
202+
kind: "file",
203+
action: "would-rewrite-session-snapshot-path",
204+
target: storePath,
205+
dryRunSafe: false,
206+
});
207+
});
208+
138209
it("expands home-relative cached bundled skill locations before classifying them", () => {
139210
const homeDir = path.join(root, "home");
140211
const stalePath = "~/old-runtime/node_modules/openclaw/skills/doctor/SKILL.md";
@@ -456,8 +527,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
456527
});
457528

458529
const raw = await fs.readFile(storePath, "utf-8");
459-
expect(raw).not.toContain(stalePath);
460-
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
530+
const snapshot = readMainSkillsSnapshot(raw);
531+
expect(snapshot.prompt).not.toContain(stalePath);
532+
expect(snapshot.prompt).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
461533
expect(note).toHaveBeenCalledTimes(1);
462534
const [message] = note.mock.calls[0] as [string, string];
463535
expect(message).toContain("Repaired");
@@ -535,9 +607,13 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
535607

536608
const raw = await fs.readFile(storePath, "utf-8");
537609
const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
538-
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
539-
expect(raw).toContain(expectedBaseDir);
540-
expect(raw).not.toContain(path.join(root, "old-runtime"));
610+
const expectedPath = path.join(bundledSkillsDir, "doctor", "SKILL.md");
611+
const snapshot = readMainSkillsSnapshot(raw);
612+
const skill = snapshot.resolvedSkills?.[0];
613+
expect(skill?.filePath).toBe(expectedPath);
614+
expect(skill?.baseDir).toBe(expectedBaseDir);
615+
expect(skill?.sourceInfo.path).toBe(expectedPath);
616+
expect(skill?.sourceInfo.baseDir).toBe(expectedBaseDir);
541617
expect(note).toHaveBeenCalledTimes(1);
542618
const [message] = note.mock.calls[0] as [string, string];
543619
expect(message).toContain("Repaired");
@@ -576,9 +652,12 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
576652
});
577653

578654
const raw = await fs.readFile(storePath, "utf-8");
579-
expect(raw).toContain(currentPath);
580-
expect(raw).toContain(path.dirname(currentPath));
581-
expect(raw).not.toContain(path.join(root, "old-runtime"));
655+
const snapshot = readMainSkillsSnapshot(raw);
656+
const repairedSkill = snapshot.resolvedSkills?.[0];
657+
expect(repairedSkill?.filePath).toBe(currentPath);
658+
expect(repairedSkill?.baseDir).toBe(path.dirname(currentPath));
659+
expect(repairedSkill?.sourceInfo.path).toBe(currentPath);
660+
expect(repairedSkill?.sourceInfo.baseDir).toBe(path.dirname(currentPath));
582661
expect(note).toHaveBeenCalledTimes(1);
583662
const [message] = note.mock.calls[0] as [string, string];
584663
expect(message).toContain("Repaired");
@@ -743,7 +822,8 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
743822
expect(backupFiles.length).toBe(1);
744823

745824
const backupContent = await fs.readFile(path.join(dir, backupFiles[0]), "utf-8");
746-
expect(backupContent).toContain(stalePath);
825+
const backupSnapshot = readMainSkillsSnapshot(backupContent);
826+
expect(backupSnapshot.prompt).toContain(stalePath);
747827
});
748828

749829
it("is idempotent — second repair finds nothing", async () => {

src/commands/doctor-session-snapshots.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import {
1212
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js";
1313
import type { SessionEntry } from "../config/sessions/types.js";
1414
import type { OpenClawConfig } from "../config/types.openclaw.js";
15+
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
1516
import { expandHomePrefix } from "../infra/home-dir.js";
1617
import { writeTextAtomic } from "../infra/json-files.js";
1718
import { resolveBundledSkillsDir } from "../skills/loading/bundled-dir.js";
1819
import { shortenHomePath } from "../utils.js";
1920

21+
const SESSION_SNAPSHOTS_CHECK_ID = "core/doctor/session-snapshots";
22+
2023
type SnapshotPathSource =
2124
| "skillsSnapshot.prompt"
2225
| "skillsSnapshot.resolvedSkills"
@@ -34,6 +37,10 @@ type StaleSessionSnapshotPathFinding = {
3437
expectedPath: string;
3538
};
3639

40+
export type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & {
41+
storePath: string;
42+
};
43+
3744
function decodeXmlText(value: string): string {
3845
return value
3946
.replace(/&lt;/g, "<")
@@ -286,6 +293,72 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record<string, Sess
286293
return store;
287294
}
288295

296+
export async function detectSessionSnapshotHealthIssues(params?: {
297+
storePaths?: string[];
298+
bundledSkillsDir?: string;
299+
cfg?: OpenClawConfig;
300+
env?: NodeJS.ProcessEnv;
301+
}): Promise<SessionSnapshotHealthIssue[]> {
302+
const bundledSkillsDir = params?.bundledSkillsDir ?? resolveBundledSkillsDir();
303+
if (!bundledSkillsDir) {
304+
return [];
305+
}
306+
const storePaths =
307+
params?.storePaths ??
308+
resolveSessionStorePaths({ cfg: params?.cfg, env: params?.env }) ??
309+
(await listSessionStorePaths(resolveStateDir(params?.env)));
310+
const issues: SessionSnapshotHealthIssue[] = [];
311+
for (const storePath of storePaths) {
312+
let store: Record<string, SessionEntry>;
313+
try {
314+
store = loadSessionStoreForSnapshotScan(storePath);
315+
} catch {
316+
continue;
317+
}
318+
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
319+
store,
320+
bundledSkillsDir,
321+
env: params?.env,
322+
});
323+
for (const finding of findings) {
324+
issues.push({
325+
sessionKey: finding.sessionKey,
326+
field: finding.field,
327+
cachedPath: finding.cachedPath,
328+
expectedPath: finding.expectedPath,
329+
storePath,
330+
});
331+
}
332+
}
333+
return issues;
334+
}
335+
336+
export function sessionSnapshotIssueToHealthFinding(
337+
issue: SessionSnapshotHealthIssue,
338+
): HealthFinding {
339+
return {
340+
checkId: SESSION_SNAPSHOTS_CHECK_ID,
341+
severity: "info",
342+
message: `${issue.sessionKey} cached session metadata references an inactive runtime root that can be cleaned up.`,
343+
path: issue.storePath,
344+
target: issue.cachedPath,
345+
requirement: `Current bundled skill path: ${issue.expectedPath}`,
346+
fixHint:
347+
"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.",
348+
};
349+
}
350+
351+
export function sessionSnapshotIssueToRepairEffect(
352+
issue: SessionSnapshotHealthIssue,
353+
): HealthRepairEffect {
354+
return {
355+
kind: "file",
356+
action: "would-rewrite-session-snapshot-path",
357+
target: issue.storePath,
358+
dryRunSafe: false,
359+
};
360+
}
361+
289362
/** Replaces stale paths in raw, JSON-escaped, and XML-escaped prompt text. */
290363
function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPathFinding): string {
291364
const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1);

src/commands/doctor-session-transcripts.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
1212
}));
1313

1414
import {
15+
detectSessionTranscriptHealthIssues,
1516
noteSessionTranscriptHealth,
1617
repairBrokenSessionTranscriptFile,
18+
sessionTranscriptIssueToHealthFinding,
19+
sessionTranscriptIssueToRepairEffect,
1720
} from "./doctor-session-transcripts.js";
1821

1922
function countNonEmptyLines(value: string): number {
@@ -150,6 +153,44 @@ describe("doctor session transcript repair", () => {
150153
expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3);
151154
});
152155

156+
it("maps affected transcripts to structured findings and dry-run effects", async () => {
157+
const filePath = await writeTranscript([
158+
{ type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" },
159+
{
160+
type: "message",
161+
id: "legacy-assistant",
162+
parentId: null,
163+
message: {
164+
role: "assistant",
165+
provider: "openai-codex",
166+
api: "openai-codex-responses",
167+
content: [{ type: "text", text: "hello" }],
168+
},
169+
},
170+
]);
171+
const sessionsDir = path.dirname(filePath);
172+
173+
const [issue] = await detectSessionTranscriptHealthIssues({ sessionDirs: [sessionsDir] });
174+
175+
if (!issue) {
176+
throw new Error("expected session transcript health issue");
177+
}
178+
expect(issue?.filePath).toBe(filePath);
179+
expect(sessionTranscriptIssueToHealthFinding(issue)).toMatchObject({
180+
checkId: "core/doctor/session-transcripts",
181+
severity: "info",
182+
path: filePath,
183+
fixHint: expect.stringContaining("openclaw doctor --fix"),
184+
});
185+
expect(sessionTranscriptIssueToRepairEffect(issue)).toEqual({
186+
kind: "file",
187+
action: "would-rewrite-session-transcript",
188+
target: filePath,
189+
dryRunSafe: false,
190+
});
191+
expect(await fs.readFile(filePath, "utf-8")).toContain("openai-codex");
192+
});
193+
153194
it("repairs supported current-version linear transcripts", async () => {
154195
const filePath = await writeTranscript([
155196
{ type: "session", version: 3, id: "session-linear", timestamp: "2026-06-15T00:00:00Z" },

0 commit comments

Comments
 (0)