fix(daemon): preserve managed files before startup refresh

This commit is contained in:
dev-driver committed 2026-10-23 07:06:21 +00:00
1 parent a1cd9ebf3b
commit b3e03ed75b
7 files changed
+141 -18

No files matched your search

@@ -225,11 +225,17 @@ host in front of you.
process are gone. If the command returns while either remains, stop: inspect
identity and use the host's authorized recovery procedure. Do not blindly
signal a PID.
7. Start the target with `rig daemon start` or the host's known wrapper. Verify
daemon status, process path, listener, version, and database integrity.
8. Run the plugin refresh helper in plan mode. Apply safe writes only after the
three roots and classifications make sense; resolve preserved paths one at a
time when required.
7. Before starting the target, account for its startup refresh of managed plugins
and marked global skills. This runtime preserves differing replaced files under
`$OPENRIG_HOME/backups/managed-refresh/` and reports each recovery path; if the
target lacks that preservation, copy those trees first. Start it with
`rig daemon start` or the host's known wrapper. Verify daemon status, process
path, listener, version, and database integrity. Recovery copies retain bytes;
they do not promise custom behaviour remains active after refresh.
8. Run the plugin refresh helper in plan mode. Its plan governs only the helper's
later writes, not startup writes that already happened. Apply safe writes only
after the three roots and classifications make sense; resolve preserved paths
one at a time when required.
9. Verify protected seats with `rig ps --nodes -A --json`, representative
`rig capture`, and `rig restore-check` where applicable. `-A` is required:
without it the node read is your CURRENT rig only, and a daemon upgrade
@@ -22,11 +22,13 @@
// expected normal-state response until a separate publish authorization.
import nodePath from "node:path";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
export interface PluginVendorFs {
readFile(path: string): string;
writeFile(path: string, content: string): void;
/** Copy raw bytes, failing if the recovery path already exists. */
copyFileExclusive(source: string, target: string): void;
exists(path: string): boolean;
mkdirp(path: string): void;
listFiles(dir: string): string[];
@@ -168,13 +170,13 @@ export class PluginVendorService {
}
}
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
this.fs.mkdirp(targetDir);
const files = this.fs.listFiles(sourceDir);
for (const relPath of files) {
for (const [relPath, content] of updates) {
const srcPath = nodePath.join(sourceDir, relPath);
const destPath = nodePath.join(targetDir, relPath);
const content = this.fs.readFile(srcPath);
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
@@ -187,6 +189,25 @@ export class PluginVendorService {
}
}
/** Preserve every replaced file before touching a target, including its version authority. */
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
try {
for (const [rel, content] of new Map(updates)) {
const dest = nodePath.join(targetDir, rel);
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
const backup = nodePath.join(backupDir, rel);
this.fs.mkdirp(nodePath.dirname(backup));
this.fs.copyFileExclusive(dest, backup);
this.logger(`[plugin-vendor] preserved ${dest} at ${backup}; custom behaviour may no longer be active after refresh`);
}
return true;
} catch (error) {
this.logger(`[plugin-vendor] preservation failed for ${targetDir}: ${(error as Error).message}; refresh skipped, existing files kept`);
return false;
}
}
/**
* Reapply the source file's permission bits to the staged/projected dest. Plain
* readFile+writeFile (writeFileSync) creates the dest with the process default mode,
@@ -255,11 +276,12 @@ export class PluginVendorService {
continue;
}
}
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
this.fs.mkdirp(targetDir);
for (const relPath of files) {
for (const [relPath, content] of updates) {
const srcPath = nodePath.join(sourceDir, relPath);
const destPath = nodePath.join(targetDir, relPath);
const content = this.fs.readFile(srcPath);
if (
this.fs.exists(destPath) &&
hashContent(this.fs.readFile(destPath)) === hashContent(content)
+1
View File
@@ -888,6 +888,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const realFs = {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
listFiles: (dir: string) => {
@@ -29,6 +29,7 @@ function realVendorFs(): PluginVendorFs {
w(dir, "");
return r;
},
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
statMode: (p) => fs.statSync(p).mode,
chmod: (p, m) => fs.chmodSync(p, m),
};
@@ -221,3 +222,86 @@ describe("PluginVendorService release-version authority", () => {
}
});
});
describe("managed refresh preserves recoverable edits", () => {
function upgrade(kind: "plugin" | "plugin skill" | "packaged skill", failBackup = false) {
const base = fs.mkdtempSync(nodePath.join(os.tmpdir(), "vendor-preserve-"));
const assets = nodePath.join(base, "assets"); seedAssets(assets);
const userPlugins = nodePath.join(base, "home", "plugins");
const globalRoot = nodePath.join(base, "global");
const plugin = nodePath.join(userPlugins, "openrig-core");
const source = kind === "plugin" ? nodePath.join(assets, "openrig-core") : plugin;
const target = kind === "plugin" ? plugin : nodePath.join(globalRoot, "compaction-restore");
const rel = kind === "plugin" ? "skills/compaction-restore/SKILL.md" : "SKILL.md";
const dest = nodePath.join(target, rel);
const logs: string[] = [];
const ops = realVendorFs();
const svc = new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: userPlugins, fs: ops, httpClient,
logger: (...args) => logs.push(args.map(String).join(" ")) });
const manifest = (dir: string, version: string) => {
for (const harness of [".claude-plugin", ".codex-plugin"]) {
fs.mkdirSync(nodePath.join(dir, harness), { recursive: true });
fs.writeFileSync(nodePath.join(dir, harness, "plugin.json"), JSON.stringify({ name: "openrig-core", version }));
}
};
// Global projection reads a staged plugin; no daemon or native runtime involved.
if (kind !== "plugin") {
fs.mkdirSync(nodePath.join(plugin, "skills/compaction-restore"), { recursive: true });
fs.writeFileSync(nodePath.join(plugin, "skills/compaction-restore/SKILL.md"), "# compaction-restore\n");
manifest(plugin, "0.1.0");
}
fs.mkdirSync(nodePath.dirname(dest), { recursive: true });
const edited = Buffer.from([0xff, 0, ...Buffer.from("my unique edit\r\n")]);
fs.writeFileSync(dest, edited);
if (kind === "plugin") manifest(target, "0.0.1");
else fs.writeFileSync(nodePath.join(target, ".openrig-vendor-version"), "0.0.1\n");
if (failBackup) ops.copyFileExclusive = () => { throw Error("fixture backup denied"); };
const refresh = () => kind === "plugin" ? svc.ensureVendored("openrig-core")
: kind === "plugin skill" ? svc.ensureSkillGlobally("openrig-core", "compaction-restore", [globalRoot])
: svc.ensureSkillDirGlobally(nodePath.join(plugin, "skills/compaction-restore"), "compaction-restore", JSON.parse(fs.readFileSync(nodePath.join(source, ".claude-plugin/plugin.json"), "utf8")).version, [globalRoot]);
const backupFiles = () => {
const dir = nodePath.join(base, "home", "backups", "managed-refresh");
return fs.existsSync(dir) ? ops.listFiles(dir).map(rel => nodePath.join(dir, rel)) : [];
};
return { base, source, target, dest, edited, logs, manifest, refresh, backupFiles, ops };
}
it.each(["plugin", "plugin skill", "packaged skill"] as const)("%s: recovers exact edited bytes after refresh and keeps earlier recovery copies across retries", async kind => {
const f = upgrade(kind);
try {
// Fail the first target write after preservation, then retry the same version with another edit.
const write = f.ops.writeFile;
let failWrite = true;
f.ops.writeFile = (file, content) => {
if (failWrite && file.startsWith(f.target + nodePath.sep)) { failWrite = false; throw Error("fixture interrupted refresh"); }
write(file, content);
};
await expect(Promise.resolve().then(f.refresh)).rejects.toThrow("fixture interrupted refresh");
const saved = f.backupFiles().find(file => fs.readFileSync(file).equals(f.edited));
expect(saved).toBeDefined();
expect(f.logs.join("\n")).toContain(saved);
expect(f.logs.join("\n")).toContain("may no longer be active");
const nextEdit = Buffer.from("a second unique edit\n"); fs.writeFileSync(f.dest, nextEdit);
await f.refresh();
expect(fs.readFileSync(f.dest, "utf8")).toBe("# compaction-restore\n");
expect(fs.readFileSync(saved!)).toEqual(f.edited);
const second = f.backupFiles().find(file => fs.readFileSync(file).equals(nextEdit));
expect(second).toBeDefined();
await f.refresh(); // equal-version retry leaves both recovery copies intact
expect(fs.readFileSync(saved!)).toEqual(f.edited);
expect(fs.readFileSync(second!)).toEqual(nextEdit);
} finally { fs.rmSync(f.base, { recursive: true, force: true }); }
});
it.each(["plugin", "plugin skill", "packaged skill"] as const)("%s: a failed preservation leaves all target files and version authority unchanged and warns", async kind => {
const f = upgrade(kind, true);
try {
const before = Object.fromEntries(realVendorFs().listFiles(f.target).map(rel => [rel, fs.readFileSync(nodePath.join(f.target, rel))]));
await expect(Promise.resolve().then(f.refresh)).resolves.toBeUndefined();
for (const [rel, bytes] of Object.entries(before)) expect(fs.readFileSync(nodePath.join(f.target, rel))).toEqual(bytes);
expect(f.logs.join("\n")).toContain("fixture backup denied");
expect(f.logs.join("\n")).toContain("refresh skipped");
} finally { fs.rmSync(f.base, { recursive: true, force: true }); }
});
});
@@ -25,6 +25,10 @@ function mockFs(initialFiles?: Record<string, string>) {
return {
readFile: (p: string) => { if (p in store) return store[p]!; throw new Error(`Not found: ${p}`); },
writeFile: (p: string, c: string) => { store[p] = c; },
copyFileExclusive: (src: string, dest: string) => {
if (dest in store) throw Error(`Recovery path exists: ${dest}`);
store[dest] = store[src]!;
},
exists: (p: string) => p in store || Object.keys(store).some((k) => k.startsWith(p + "/")),
mkdirp: () => {},
listFiles: (dir: string) => Object.keys(store).filter((k) => k.startsWith(dir + "/")).map((k) => k.slice(dir.length + 1)),
+2 -2
View File
@@ -13,7 +13,7 @@
"core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
"core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77",
"core/openrig-software-factory/references/worked-example.md": "409680b7e96e1b5725a68fd7e96da0a4c908fec88a77599d39f574d9e64bcc88",
"core/openrig-upgrade/SKILL.md": "0ddbe16d8c93cd093e5fd3bd11a4323479db46a6d49ae9906df940b699f6e297",
"core/openrig-upgrade/SKILL.md": "5653ccb547ef22ecb4afe3d9a376e522997d1ebf44639690a875cdc5d7447951",
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4",
"core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a",
"core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f",
@@ -112,7 +112,7 @@
"core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
"core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77",
"core/openrig-software-factory/references/worked-example.md": "409680b7e96e1b5725a68fd7e96da0a4c908fec88a77599d39f574d9e64bcc88",
"core/openrig-upgrade/SKILL.md": "0ddbe16d8c93cd093e5fd3bd11a4323479db46a6d49ae9906df940b699f6e297",
"core/openrig-upgrade/SKILL.md": "5653ccb547ef22ecb4afe3d9a376e522997d1ebf44639690a875cdc5d7447951",
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4",
"core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a",
"core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f",
@@ -225,11 +225,17 @@ host in front of you.
process are gone. If the command returns while either remains, stop: inspect
identity and use the host's authorized recovery procedure. Do not blindly
signal a PID.
7. Start the target with `rig daemon start` or the host's known wrapper. Verify
daemon status, process path, listener, version, and database integrity.
8. Run the plugin refresh helper in plan mode. Apply safe writes only after the
three roots and classifications make sense; resolve preserved paths one at a
time when required.
7. Before starting the target, account for its startup refresh of managed plugins
and marked global skills. This runtime preserves differing replaced files under
`$OPENRIG_HOME/backups/managed-refresh/` and reports each recovery path; if the
target lacks that preservation, copy those trees first. Start it with
`rig daemon start` or the host's known wrapper. Verify daemon status, process
path, listener, version, and database integrity. Recovery copies retain bytes;
they do not promise custom behaviour remains active after refresh.
8. Run the plugin refresh helper in plan mode. Its plan governs only the helper's
later writes, not startup writes that already happened. Apply safe writes only
after the three roots and classifications make sense; resolve preserved paths
one at a time when required.
9. Verify protected seats with `rig ps --nodes -A --json`, representative
`rig capture`, and `rig restore-check` where applicable. `-A` is required:
without it the node read is your CURRENT rig only, and a daemon upgrade