mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 15:14:06 +00:00
fix: repair doctor session snapshot paths safely
This commit is contained in:
@@ -1,354 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* repro-doctor-session-snapshot-repair.mjs
|
||||
*
|
||||
* Real Behavior Proof for PR #85691:
|
||||
* fix(doctor): auto-repair stale session snapshot paths on --fix
|
||||
*
|
||||
* Zero dependencies — run with: node repro-doctor-session-snapshot-repair.mjs
|
||||
*
|
||||
* Mirrors the exact repair logic from the PR (v2 — layered on current main scanner):
|
||||
* - Uses extractBundledSkillRelativeSegments + resolveExpectedBundledSkillPath pattern
|
||||
* - Scoped replacement: only modifies snapshot metadata fields
|
||||
* - Handles JSON-escaped, XML-escaped, and raw path forms
|
||||
* - Creates .bak backup before writing
|
||||
* - Validates JSON integrity after repair
|
||||
*
|
||||
* Date: 2026-05-29
|
||||
* Node: v24.14.0
|
||||
* Platform: linux x64
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync, copyFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const testDir = join(tmpdir(), `openclaw-doctor-proof-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
const staleRoot = "/home/user/.local/share/pnpm/global/5/node_modules/openclaw@2026.5.20_@types+express@5.0.6";
|
||||
const liveRoot = "/home/user/.local/share/pnpm/global/5/node_modules/openclaw@2026.5.20";
|
||||
|
||||
function splitPathSegments(value) {
|
||||
return value.replace(/^[a-z]:/i, "").replaceAll("\\", "/").split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function isWindowsAbsolutePath(value) {
|
||||
return (/^[a-z]:/i.test(value) && ["/", "\\"].includes(value.slice(2, 3))) || value.startsWith("\\\\");
|
||||
}
|
||||
|
||||
function isTempBackedOpenClawRoot(segments) {
|
||||
const lower = segments.map(s => s.toLowerCase());
|
||||
const idx = lower.lastIndexOf("openclaw");
|
||||
return idx >= 1 && (lower[idx - 1] === "tmp" || lower[idx - 1] === "temp");
|
||||
}
|
||||
|
||||
function isBundledRuntimeSkillsPath(cachedPath, skillRootIndex) {
|
||||
const before = splitPathSegments(cachedPath).slice(0, skillRootIndex);
|
||||
const lower = before.map(s => s.toLowerCase());
|
||||
return lower.some(s => s === "dist-runtime" || s === "node_modules" || s.startsWith("openclaw@"))
|
||||
|| isTempBackedOpenClawRoot(before);
|
||||
}
|
||||
|
||||
function extractBundledSkillRelativeSegments(cachedPath) {
|
||||
const segments = splitPathSegments(cachedPath);
|
||||
const skillRootIndex = segments.lastIndexOf("skills");
|
||||
if (skillRootIndex < 0 || !isBundledRuntimeSkillsPath(cachedPath, skillRootIndex)) return undefined;
|
||||
const rel = segments.slice(skillRootIndex + 1);
|
||||
if (rel.length < 2 || rel.at(-1) !== "SKILL.md") return undefined;
|
||||
return rel;
|
||||
}
|
||||
|
||||
function resolveExpectedBundledSkillPath(cachedPath, bundledSkillsDir) {
|
||||
const segments = splitPathSegments(cachedPath);
|
||||
const rel = extractBundledSkillRelativeSegments(cachedPath);
|
||||
if (!rel) return undefined;
|
||||
const expected = [bundledSkillsDir, ...rel].join("/");
|
||||
return expected;
|
||||
}
|
||||
|
||||
function decodeXmlText(value) {
|
||||
return value
|
||||
.replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function extractSkillLocations(prompt) {
|
||||
if (typeof prompt !== "string" || !prompt.trim()) return [];
|
||||
const locations = [];
|
||||
const pattern = /<location>([\s\S]*?)<\/location>/g;
|
||||
for (const match of prompt.matchAll(pattern)) {
|
||||
const raw = match[1]?.trim();
|
||||
if (raw) locations.push(decodeXmlText(raw));
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
function collectResolvedSkillPaths(resolvedSkills) {
|
||||
if (!Array.isArray(resolvedSkills)) return [];
|
||||
const paths = [];
|
||||
for (const skill of resolvedSkills) {
|
||||
if (!skill || typeof skill !== "object") continue;
|
||||
if (typeof skill.filePath === "string" && skill.filePath.trim()) paths.push(skill.filePath.trim());
|
||||
if (typeof skill.baseDir === "string" && skill.baseDir.trim()) paths.push(skill.baseDir.trim() + "/SKILL.md");
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function collectInjectedWorkspaceFilePaths(injected) {
|
||||
if (!Array.isArray(injected)) return [];
|
||||
return injected
|
||||
.map(e => (e && typeof e === "object" && typeof e.path === "string" ? e.path.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectCachedSnapshotPaths(entry) {
|
||||
const snapshot = entry.skillsSnapshot;
|
||||
const report = entry.systemPromptReport;
|
||||
const paths = [];
|
||||
for (const loc of extractSkillLocations(snapshot?.prompt)) {
|
||||
paths.push({ field: "skillsSnapshot.prompt", path: loc });
|
||||
}
|
||||
for (const loc of collectResolvedSkillPaths(snapshot?.resolvedSkills)) {
|
||||
paths.push({ field: "skillsSnapshot.resolvedSkills", path: loc });
|
||||
}
|
||||
if (report && typeof report === "object") {
|
||||
for (const loc of collectInjectedWorkspaceFilePaths(report.injectedWorkspaceFiles)) {
|
||||
paths.push({ field: "systemPromptReport.injectedWorkspaceFiles", path: loc });
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isInsidePath(baseDir, candidatePath) {
|
||||
const baseIsWin = isWindowsAbsolutePath(baseDir);
|
||||
const candIsWin = isWindowsAbsolutePath(candidatePath);
|
||||
if (baseIsWin !== candIsWin) return false;
|
||||
const normBase = baseDir.replaceAll("\\", "/");
|
||||
const normCand = candidatePath.replaceAll("\\", "/");
|
||||
return normCand.startsWith(normBase + "/") || normCand === normBase;
|
||||
}
|
||||
|
||||
function findStalePaths(store, bundledSkillsDir) {
|
||||
const findings = [];
|
||||
const seen = new Set();
|
||||
for (const [sessionKey, entry] of Object.entries(store)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
for (const cached of collectCachedSnapshotPaths(entry)) {
|
||||
if (isInsidePath(bundledSkillsDir, cached.path)) continue;
|
||||
const expectedPath = resolveExpectedBundledSkillPath(cached.path, bundledSkillsDir);
|
||||
if (!expectedPath) continue;
|
||||
const key = `${sessionKey}\0${cached.field}\0${cached.path}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({ sessionKey, field: cached.field, cachedPath: cached.path, expectedPath });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function replacePathsInSession(session, finding) {
|
||||
let count = 0;
|
||||
const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1);
|
||||
const jsonEscapedExpected = JSON.stringify(finding.expectedPath).slice(1, -1);
|
||||
const xmlEscaped = finding.cachedPath.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
const xmlEscapedExpected = finding.expectedPath.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
|
||||
if (finding.field === "skillsSnapshot.prompt") {
|
||||
const snapshot = session.skillsSnapshot;
|
||||
if (snapshot && typeof snapshot.prompt === "string") {
|
||||
let prompt = snapshot.prompt;
|
||||
const original = prompt;
|
||||
if (prompt.includes(jsonEscaped)) { count += prompt.split(jsonEscaped).length - 1; prompt = prompt.replaceAll(jsonEscaped, jsonEscapedExpected); }
|
||||
if (prompt.includes(xmlEscaped)) { count += prompt.split(xmlEscaped).length - 1; prompt = prompt.replaceAll(xmlEscaped, xmlEscapedExpected); }
|
||||
if (prompt.includes(finding.cachedPath)) { count += prompt.split(finding.cachedPath).length - 1; prompt = prompt.replaceAll(finding.cachedPath, finding.expectedPath); }
|
||||
if (prompt !== original) snapshot.prompt = prompt;
|
||||
}
|
||||
} else if (finding.field === "skillsSnapshot.resolvedSkills") {
|
||||
const snapshot = session.skillsSnapshot;
|
||||
if (snapshot && Array.isArray(snapshot.resolvedSkills)) {
|
||||
for (const entry of snapshot.resolvedSkills) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
for (const field of ["filePath", "baseDir"]) {
|
||||
if (typeof entry[field] !== "string") continue;
|
||||
let value = entry[field];
|
||||
const original = value;
|
||||
const candidates = [
|
||||
{ cached: jsonEscaped, expected: jsonEscapedExpected },
|
||||
{ cached: finding.cachedPath, expected: finding.expectedPath },
|
||||
];
|
||||
if (field === "baseDir") {
|
||||
for (const suffix of ["/SKILL.md", "\\SKILL.md"]) {
|
||||
if (finding.cachedPath.endsWith(suffix)) {
|
||||
const cachedDir = finding.cachedPath.slice(0, -suffix.length);
|
||||
const expectedDir = finding.expectedPath.slice(0, -suffix.length);
|
||||
candidates.push(
|
||||
{ cached: JSON.stringify(cachedDir).slice(1, -1), expected: JSON.stringify(expectedDir).slice(1, -1) },
|
||||
{ cached: cachedDir, expected: expectedDir },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { cached, expected } of candidates) {
|
||||
if (value.includes(cached)) { count += value.split(cached).length - 1; value = value.replaceAll(cached, expected); }
|
||||
}
|
||||
if (value !== original) entry[field] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (finding.field === "systemPromptReport.injectedWorkspaceFiles") {
|
||||
const report = session.systemPromptReport;
|
||||
if (report && Array.isArray(report.injectedWorkspaceFiles)) {
|
||||
for (const entry of report.injectedWorkspaceFiles) {
|
||||
if (!entry || typeof entry.path !== "string") continue;
|
||||
let entryPath = entry.path;
|
||||
const original = entryPath;
|
||||
for (const { cached, expected } of [
|
||||
{ cached: jsonEscaped, expected: jsonEscapedExpected },
|
||||
{ cached: finding.cachedPath, expected: finding.expectedPath },
|
||||
]) {
|
||||
if (entryPath.includes(cached)) { count += entryPath.split(cached).length - 1; entryPath = entryPath.replaceAll(cached, expected); }
|
||||
}
|
||||
if (entryPath !== original) entry.path = entryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("─".repeat(72));
|
||||
console.log("Real Behavior Proof: Doctor Session Snapshot Auto-Repair");
|
||||
console.log(`Date: ${new Date().toISOString()}`);
|
||||
console.log(`Node: ${process.version} | Platform: ${process.platform} ${process.arch}`);
|
||||
console.log("─".repeat(72));
|
||||
console.log();
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
|
||||
// Scenario 1: WITHOUT repair
|
||||
console.log("Scenario 1: WITHOUT repair — findings computed but not applied");
|
||||
const sessions1 = {
|
||||
"session-1": {
|
||||
skillsSnapshot: {
|
||||
prompt: `Use <location>${staleRoot}/skills/my-skill/SKILL.md</location>`,
|
||||
resolvedSkills: [{ id: "my-skill", baseDir: `${staleRoot}/skills/my-skill` }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const findings1 = findStalePaths(sessions1, liveRoot);
|
||||
console.log(` Findings computed: ${findings1.length} stale paths`);
|
||||
console.log(` Repair performed: NO (shouldRepair not enabled)`);
|
||||
if (findings1.length === 2) { console.log(" PASS"); passed++; } else { console.log(` FAIL: expected 2, got ${findings1.length}`); failed++; }
|
||||
console.log();
|
||||
|
||||
// Scenario 2: WITH repair
|
||||
console.log("Scenario 2: WITH repair — paths replaced correctly");
|
||||
const sessions2 = JSON.parse(JSON.stringify(sessions1));
|
||||
const file2 = join(testDir, "sessions2.json");
|
||||
writeFileSync(file2, JSON.stringify(sessions2, null, 2));
|
||||
const backup2 = `${file2}.bak`;
|
||||
copyFileSync(file2, backup2);
|
||||
|
||||
let totalCount = 0;
|
||||
for (const finding of findStalePaths(sessions2, liveRoot)) {
|
||||
totalCount += replacePathsInSession(sessions2[finding.sessionKey], finding);
|
||||
}
|
||||
writeFileSync(file2, JSON.stringify(sessions2, null, 2));
|
||||
|
||||
const remaining2 = findStalePaths(sessions2, liveRoot);
|
||||
const jsonValid2 = (() => { try { JSON.parse(readFileSync(file2, "utf-8")); return true; } catch { return false; } })();
|
||||
console.log(` Paths replaced: ${totalCount}`);
|
||||
console.log(` Backup created: ${existsSync(backup2)}`);
|
||||
console.log(` JSON valid: ${jsonValid2}`);
|
||||
console.log(` Stale paths remaining: ${remaining2.length}`);
|
||||
if (totalCount === 2 && existsSync(backup2) && jsonValid2 && remaining2.length === 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; }
|
||||
console.log();
|
||||
|
||||
// Scenario 3: Windows backslash paths
|
||||
console.log("Scenario 3: Windows backslash paths — JSON-escaped in file");
|
||||
const winStale = "C:\\Users\\user\\.local\\share\\pnpm\\global\\5\\node_modules\\openclaw@2026.5.20_@types+express@5.0.6";
|
||||
const winLive = "C:\\Users\\user\\.local\\share\\pnpm\\global\\5\\node_modules\\openclaw@2026.5.20";
|
||||
const winSessions = { "session-win": { skillsSnapshot: { prompt: `Use <location>${winStale}/skills/my-skill/SKILL.md</location>` } } };
|
||||
const winFindings = findStalePaths(winSessions, winLive);
|
||||
let winCount = 0;
|
||||
for (const f of winFindings) winCount += replacePathsInSession(winSessions[f.sessionKey], f);
|
||||
const winRepaired = JSON.stringify(winSessions);
|
||||
const hasWinLive = winRepaired.includes(JSON.stringify(winLive).slice(1, -1));
|
||||
const hasWinStale = winRepaired.includes(JSON.stringify(winStale).slice(1, -1));
|
||||
console.log(` Repaired contains live root: ${hasWinLive}`);
|
||||
console.log(` Repaired contains stale root: ${hasWinStale}`);
|
||||
if (hasWinLive && !hasWinStale && winCount > 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; }
|
||||
console.log();
|
||||
|
||||
// Scenario 4: XML-escaped prompt paths with & character
|
||||
console.log("Scenario 4: XML-escaped prompt paths — entity-encoded in file");
|
||||
const xmlStale = "/home/user/projects/my&company/openclaw@2026.5.20_@types+express@5.0.6";
|
||||
const xmlLive = "/home/user/projects/my&company/openclaw@2026.5.20";
|
||||
const xmlEscapedStale = xmlStale.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
const xmlSessions = { "session-xml": { skillsSnapshot: { prompt: `Use <location>${xmlEscapedStale}/skills/my-skill/SKILL.md</location>` } } };
|
||||
const xmlFindings = findStalePaths(xmlSessions, xmlLive);
|
||||
let xmlCount = 0;
|
||||
for (const f of xmlFindings) xmlCount += replacePathsInSession(xmlSessions[f.sessionKey], f);
|
||||
const xmlRepaired = JSON.stringify(xmlSessions);
|
||||
const xmlLiveEscaped = xmlLive.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
const hasXmlLive = xmlRepaired.includes(xmlLiveEscaped);
|
||||
const hasXmlStale = xmlRepaired.includes(xmlEscapedStale);
|
||||
console.log(` Repaired contains live root: ${hasXmlLive}`);
|
||||
console.log(` Repaired contains stale root: ${hasXmlStale}`);
|
||||
if (hasXmlLive && !hasXmlStale && xmlCount > 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; }
|
||||
console.log();
|
||||
|
||||
// Scenario 5: Idempotent
|
||||
console.log("Scenario 5: Idempotent — second run finds nothing");
|
||||
const sessions5 = JSON.parse(JSON.stringify(sessions1));
|
||||
for (const f of findStalePaths(sessions5, liveRoot)) replacePathsInSession(sessions5[f.sessionKey], f);
|
||||
const secondFindings = findStalePaths(sessions5, liveRoot);
|
||||
console.log(` Second scan findings: ${secondFindings.length}`);
|
||||
if (secondFindings.length === 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; }
|
||||
console.log();
|
||||
|
||||
// Scenario 6: Scoped replacement — unrelated content preserved
|
||||
console.log("Scenario 6: Scoped replacement — unrelated content preserved");
|
||||
const sessions6 = {
|
||||
"session-scoped": {
|
||||
skillsSnapshot: { prompt: `Use <location>${staleRoot}/skills/my-skill/SKILL.md</location>` },
|
||||
transcript: `User mentioned ${staleRoot} in their message. This should NOT be modified.`,
|
||||
userMessage: `I have a file at ${staleRoot}/some/file.txt that needs attention.`,
|
||||
},
|
||||
"session-other": {
|
||||
skillsSnapshot: { prompt: `Another skill at <location>${staleRoot}/skills/other-skill/SKILL.md</location>` },
|
||||
},
|
||||
};
|
||||
let scopedCount = 0;
|
||||
for (const f of findStalePaths(sessions6, liveRoot)) scopedCount += replacePathsInSession(sessions6[f.sessionKey], f);
|
||||
const transcriptOk = sessions6["session-scoped"].transcript.includes(staleRoot);
|
||||
const userMsgOk = sessions6["session-scoped"].userMessage.includes(staleRoot);
|
||||
const promptOk = !sessions6["session-scoped"].skillsSnapshot.prompt.includes(staleRoot);
|
||||
const otherOk = !sessions6["session-other"].skillsSnapshot.prompt.includes(staleRoot);
|
||||
console.log(` Paths replaced: ${scopedCount}`);
|
||||
console.log(` Transcript preserved: ${transcriptOk}`);
|
||||
console.log(` User message preserved: ${userMsgOk}`);
|
||||
console.log(` Prompt repaired: ${promptOk}`);
|
||||
console.log(` Other session repaired: ${otherOk}`);
|
||||
if (transcriptOk && userMsgOk && promptOk && otherOk && scopedCount === 2) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; }
|
||||
console.log();
|
||||
|
||||
console.log("─".repeat(72));
|
||||
console.log("SUMMARY");
|
||||
console.log("─".repeat(72));
|
||||
console.log();
|
||||
console.log(` Results: ${passed} passed, ${failed} failed`);
|
||||
console.log();
|
||||
console.log(" The repair uses the existing scanner (extractBundledSkillRelativeSegments");
|
||||
console.log(" + resolveExpectedBundledSkillPath) to compute expected paths, then applies");
|
||||
console.log(" scoped replacement to only modify snapshot metadata fields.");
|
||||
console.log(" Unrelated content (transcripts, user messages) is preserved unchanged.");
|
||||
console.log("─".repeat(72));
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error("Proof script failed:", err); process.exit(1); });
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { saveSessionStore } from "../config/sessions/store.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { Skill } from "../skills/loading/skill-contract.js";
|
||||
|
||||
const note = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -37,6 +38,25 @@ function skillPrompt(location: string): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function resolvedSkill(skillPath: string): Skill {
|
||||
const baseDir = path.dirname(skillPath);
|
||||
return {
|
||||
name: "doctor",
|
||||
description: "Doctor skill",
|
||||
filePath: skillPath,
|
||||
baseDir,
|
||||
source: "bundled",
|
||||
sourceInfo: {
|
||||
path: skillPath,
|
||||
source: "bundled",
|
||||
scope: "user",
|
||||
origin: "top-level",
|
||||
baseDir,
|
||||
},
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeSessionStore(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
@@ -267,23 +287,7 @@ describe("doctor session snapshot stale runtime metadata", () => {
|
||||
skillsSnapshot: {
|
||||
prompt: "",
|
||||
skills: [{ name: "doctor" }],
|
||||
resolvedSkills: [
|
||||
{
|
||||
name: "doctor",
|
||||
description: "Doctor skill",
|
||||
source: "bundled",
|
||||
filePath: stalePath,
|
||||
baseDir: path.dirname(stalePath),
|
||||
sourceInfo: {
|
||||
path: stalePath,
|
||||
source: "bundled",
|
||||
scope: "user",
|
||||
origin: "top-level",
|
||||
baseDir: path.dirname(stalePath),
|
||||
},
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
],
|
||||
resolvedSkills: [resolvedSkill(stalePath)],
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -425,7 +429,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("repairs stale inline prompt paths", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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({
|
||||
@@ -436,7 +448,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
expect(raw).not.toContain(stalePath);
|
||||
@@ -447,7 +463,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("repairs stale promptRef blob paths", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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");
|
||||
const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`;
|
||||
await saveSessionStore(
|
||||
@@ -467,7 +491,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
expect(rawBefore).toContain("promptRef");
|
||||
expect(rawBefore).not.toContain(stalePath);
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
const rawAfter = await fs.readFile(storePath, "utf-8");
|
||||
expect(rawAfter).toContain("promptRef");
|
||||
@@ -478,25 +506,31 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("repairs stale resolvedSkills filePath and baseDir", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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: "",
|
||||
skills: [{ name: "doctor" }],
|
||||
resolvedSkills: [
|
||||
{
|
||||
name: "doctor",
|
||||
filePath: stalePath,
|
||||
baseDir: path.dirname(stalePath),
|
||||
},
|
||||
],
|
||||
resolvedSkills: [resolvedSkill(stalePath)],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
|
||||
@@ -508,8 +542,57 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
expect(message).toContain("Repaired");
|
||||
});
|
||||
|
||||
it("repairs stale resolvedSkills sourceInfo paths after top-level fields are current", async () => {
|
||||
const stalePath = path.join(
|
||||
root,
|
||||
"old-runtime",
|
||||
"node_modules",
|
||||
"openclaw",
|
||||
"skills",
|
||||
"doctor",
|
||||
"SKILL.md",
|
||||
);
|
||||
const currentPath = path.join(bundledSkillsDir, "doctor", "SKILL.md");
|
||||
const skill = resolvedSkill(currentPath);
|
||||
skill.sourceInfo.path = stalePath;
|
||||
skill.sourceInfo.baseDir = path.dirname(stalePath);
|
||||
|
||||
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
|
||||
await writeSessionStore(storePath, {
|
||||
"agent:main": sessionEntry({
|
||||
skillsSnapshot: {
|
||||
prompt: "",
|
||||
skills: [{ name: "doctor" }],
|
||||
resolvedSkills: [skill],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
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"));
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const [message] = note.mock.calls[0] as [string, string];
|
||||
expect(message).toContain("Repaired");
|
||||
});
|
||||
|
||||
it("preserves sessions with missing promptRef blobs", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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:healthy": sessionEntry({
|
||||
@@ -520,13 +603,18 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
}),
|
||||
"agent:missing-blob": sessionEntry({
|
||||
skillsSnapshot: {
|
||||
prompt: "",
|
||||
promptRef: { version: 1, algorithm: "sha256", hash: "a".repeat(64), bytes: 100 },
|
||||
skills: [{ name: "doctor" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -538,7 +626,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("handles missing blob gracefully without crashing or reporting false findings", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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");
|
||||
const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`;
|
||||
await saveSessionStore(
|
||||
@@ -563,7 +659,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
|
||||
// Scanner hydration strips skillsSnapshot for missing blob,
|
||||
// so no findings are reported. Repair should noop gracefully.
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
expect(note).not.toHaveBeenCalled();
|
||||
|
||||
@@ -574,19 +674,34 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("scoped replacement preserves unrelated content", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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({
|
||||
const entry = {
|
||||
...sessionEntry({
|
||||
skillsSnapshot: {
|
||||
prompt: skillPrompt(stalePath),
|
||||
skills: [{ name: "doctor" }],
|
||||
},
|
||||
transcript: `User mentioned ${stalePath} in their message.`,
|
||||
}),
|
||||
transcript: `User mentioned ${stalePath} in their message.`,
|
||||
} satisfies SessionEntry & { transcript: string };
|
||||
await writeSessionStore(storePath, {
|
||||
"agent:main": entry,
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -596,7 +711,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("creates backup before repair", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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({
|
||||
@@ -607,7 +730,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
|
||||
const dir = path.dirname(storePath);
|
||||
const files = await fs.readdir(dir);
|
||||
@@ -619,7 +746,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
it("is idempotent — second repair finds nothing", async () => {
|
||||
const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md");
|
||||
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({
|
||||
@@ -630,11 +765,19 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
note.mockClear();
|
||||
|
||||
await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true });
|
||||
await noteSessionSnapshotHealth({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(note).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { hydrateSessionStoreSkillPromptRefs, resolveSessionSkillPromptBlobPath } from "../config/sessions/skill-prompt-blobs.js";
|
||||
import {
|
||||
hydrateSessionStoreSkillPromptRefs,
|
||||
resolveSessionSkillPromptBlobPath,
|
||||
} from "../config/sessions/skill-prompt-blobs.js";
|
||||
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -69,6 +72,14 @@ function collectResolvedSkillPaths(value: unknown): string[] {
|
||||
if (typeof skill.baseDir === "string" && skill.baseDir.trim()) {
|
||||
paths.push(path.join(skill.baseDir.trim(), "SKILL.md"));
|
||||
}
|
||||
if (isRecord(skill.sourceInfo)) {
|
||||
if (typeof skill.sourceInfo.path === "string" && skill.sourceInfo.path.trim()) {
|
||||
paths.push(skill.sourceInfo.path.trim());
|
||||
}
|
||||
if (typeof skill.sourceInfo.baseDir === "string" && skill.sourceInfo.baseDir.trim()) {
|
||||
paths.push(path.join(skill.sourceInfo.baseDir.trim(), "SKILL.md"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -277,10 +288,7 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record<string, Sess
|
||||
* Replace stale paths in raw text content (blob files or inline prompts).
|
||||
* Handles raw, JSON-escaped, and XML-escaped forms.
|
||||
*/
|
||||
function replaceStalePathsInText(
|
||||
text: string,
|
||||
finding: StaleSessionSnapshotPathFinding,
|
||||
): string {
|
||||
function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPathFinding): string {
|
||||
const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1);
|
||||
const jsonEscapedExpected = JSON.stringify(finding.expectedPath).slice(1, -1);
|
||||
const xmlEscaped = finding.cachedPath
|
||||
@@ -429,11 +437,14 @@ export async function noteSessionSnapshotHealth(params?: {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
for (const field of ["filePath", "baseDir"] as const) {
|
||||
if (typeof entry[field] !== "string") {
|
||||
continue;
|
||||
const replaceResolvedSkillField = (
|
||||
target: Record<string, unknown>,
|
||||
field: string,
|
||||
) => {
|
||||
if (typeof target[field] !== "string") {
|
||||
return;
|
||||
}
|
||||
let value = entry[field];
|
||||
let value = target[field];
|
||||
const original = value;
|
||||
const candidates = [
|
||||
{ cached: jsonEscaped, expected: jsonEscapedExpected },
|
||||
@@ -445,7 +456,10 @@ export async function noteSessionSnapshotHealth(params?: {
|
||||
const cachedDir = finding.cachedPath.slice(0, -suffix.length);
|
||||
const expectedDir = finding.expectedPath.slice(0, -suffix.length);
|
||||
candidates.push(
|
||||
{ cached: JSON.stringify(cachedDir).slice(1, -1), expected: JSON.stringify(expectedDir).slice(1, -1) },
|
||||
{
|
||||
cached: JSON.stringify(cachedDir).slice(1, -1),
|
||||
expected: JSON.stringify(expectedDir).slice(1, -1),
|
||||
},
|
||||
{ cached: cachedDir, expected: expectedDir },
|
||||
);
|
||||
}
|
||||
@@ -457,10 +471,19 @@ export async function noteSessionSnapshotHealth(params?: {
|
||||
}
|
||||
}
|
||||
if (value !== original) {
|
||||
entry[field] = value;
|
||||
target[field] = value;
|
||||
storeCount++;
|
||||
modified = true;
|
||||
}
|
||||
};
|
||||
|
||||
for (const field of ["filePath", "baseDir"]) {
|
||||
replaceResolvedSkillField(entry, field);
|
||||
}
|
||||
if (isRecord(entry.sourceInfo)) {
|
||||
for (const field of ["path", "baseDir"]) {
|
||||
replaceResolvedSkillField(entry.sourceInfo, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (finding.field === "systemPromptReport.injectedWorkspaceFiles") {
|
||||
|
||||
@@ -481,6 +481,7 @@ function migrateRetiredWebchatChannelConfig(raw: Record<string, unknown>, change
|
||||
const gateway = getRecord(raw.gateway) ?? {};
|
||||
const gatewayWebchat = getRecord(gateway.webchat) ?? {};
|
||||
const canMoveLegacyTextChunkLimit =
|
||||
typeof legacyTextChunkLimit === "number" &&
|
||||
Number.isInteger(legacyTextChunkLimit) &&
|
||||
legacyTextChunkLimit > 0 &&
|
||||
legacyTextChunkLimit <= WEBCHAT_CHAT_HISTORY_MAX_CHARS_LIMIT;
|
||||
|
||||
@@ -484,7 +484,11 @@ async function runSessionTranscriptsHealth(ctx: DoctorHealthFlowContext): Promis
|
||||
|
||||
async function runSessionSnapshotsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteSessionSnapshotHealth } = await import("../commands/doctor-session-snapshots.js");
|
||||
await noteSessionSnapshotHealth({ cfg: ctx.cfg, env: ctx.env ?? process.env, shouldRepair: ctx.prompter.shouldRepair });
|
||||
await noteSessionSnapshotHealth({
|
||||
cfg: ctx.cfg,
|
||||
env: ctx.env ?? process.env,
|
||||
shouldRepair: ctx.prompter.shouldRepair,
|
||||
});
|
||||
}
|
||||
|
||||
async function runConfigAuditScrubHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user