mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(daemon): preserve managed files before startup refresh
This commit is contained in:
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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user