

























@@ -387,6 +387,41 @@ async function runLegacyStateMigrationsForRoot(root: string) {
387387return await runLegacyStateMigrations({ detected });
388388}
389389390+function failRenameOnce(sourcePath: string) {
391+const actualRenameSync = fs.renameSync.bind(fs);
392+let failed = false;
393+return vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
394+if (!failed && String(from) === sourcePath) {
395+failed = true;
396+throw new Error("forced archive failure");
397+}
398+actualRenameSync(from, to);
399+});
400+}
401+402+function writePendingWalSnapshot(sourcePath: string, mutate: (db: DatabaseSync) => void): Buffer {
403+const walPath = `${sourcePath}-wal`;
404+const snapshotPath = `${sourcePath}.wal-snapshot`;
405+const snapshotWalPath = `${snapshotPath}-wal`;
406+const sqlite = requireNodeSqlite();
407+const db = new sqlite.DatabaseSync(sourcePath);
408+try {
409+db.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;");
410+mutate(db);
411+// Copy before closing because SQLite checkpoints and removes the WAL on clean shutdown.
412+fs.copyFileSync(sourcePath, snapshotPath);
413+fs.copyFileSync(walPath, snapshotWalPath);
414+} finally {
415+db.close();
416+}
417+for (const suffix of ["", "-shm", "-wal", "-journal"]) {
418+fs.rmSync(`${sourcePath}${suffix}`, { force: true });
419+}
420+fs.renameSync(snapshotPath, sourcePath);
421+fs.renameSync(snapshotWalPath, walPath);
422+return fs.readFileSync(walPath);
423+}
424+390425function writeLegacyTaskStateSidecars(root: string): {
391426taskRunsPath: string;
392427flowRunsPath: string;
@@ -1541,6 +1576,79 @@ describe("doctor legacy state migrations", () => {
15411576});
15421577});
154315781579+it("archives the plugin-state rollback journal with the legacy database", async () => {
1580+const root = await makeTempRoot();
1581+const sourcePath = writeLegacyPluginStateSidecar(root);
1582+const journalPath = `${sourcePath}-journal`;
1583+fs.writeFileSync(journalPath, "");
1584+1585+const result = await runLegacyStateMigrationsForRoot(root);
1586+1587+expect(result.warnings).toStrictEqual([]);
1588+expect(fs.existsSync(journalPath)).toBe(false);
1589+expect(fs.existsSync(`${journalPath}.migrated`)).toBe(true);
1590+expect(fs.existsSync(sourcePath)).toBe(false);
1591+expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
1592+});
1593+1594+it("retries plugin-state archival after a sidecar rename failure", async () => {
1595+const root = await makeTempRoot();
1596+const sourcePath = writeLegacyPluginStateSidecar(root);
1597+const walPath = `${sourcePath}-wal`;
1598+const pendingWalState = writePendingWalSnapshot(sourcePath, (db) => {
1599+db.prepare(`
1600+ UPDATE plugin_state_entries
1601+ SET value_json = ?
1602+ WHERE plugin_id = ? AND namespace = ? AND entry_key = ?
1603+ `).run('{"ok":"from-wal"}', "discord", "components", "interaction:1");
1604+});
1605+1606+const rename = failRenameOnce(walPath);
1607+const firstResult = await (async () => {
1608+try {
1609+return await runLegacyStateMigrationsForRoot(root);
1610+} finally {
1611+rename.mockRestore();
1612+}
1613+})();
1614+1615+expect(firstResult.changes).toContain(
1616+"Migrated 1 plugin-state sidecar entry → shared SQLite state",
1617+);
1618+expect(firstResult.warnings).toStrictEqual([
1619+`Failed archiving plugin-state sidecar ${walPath}: Error: forced archive failure`,
1620+]);
1621+expect(fs.existsSync(sourcePath)).toBe(false);
1622+expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
1623+expect(fs.existsSync(walPath)).toBe(true);
1624+expect(fs.existsSync(`${walPath}.migrated`)).toBe(false);
1625+1626+const retryDetected = await detectLegacyStateMigrations({
1627+cfg: {},
1628+env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
1629+});
1630+expect(retryDetected.pluginStateSidecar).toEqual({ sourcePath, hasLegacy: true });
1631+expect(retryDetected.preview).toContain(
1632+`- Plugin state sidecar: finish archive cleanup for ${sourcePath}`,
1633+);
1634+const retryResult = await runLegacyStateMigrations({ detected: retryDetected });
1635+1636+expect(retryResult.warnings).toStrictEqual([]);
1637+expect(retryResult.changes).toStrictEqual([
1638+`Archived plugin-state sidecar legacy source → ${sourcePath}.migrated`,
1639+]);
1640+expect(fs.existsSync(walPath)).toBe(false);
1641+expect(fs.readFileSync(`${walPath}.migrated`)).toEqual(pendingWalState);
1642+1643+await withStateDir(root, async () => {
1644+const store = createPluginStateKeyedStore<{ ok: string }>("discord", {
1645+namespace: "components",
1646+maxEntries: 10,
1647+});
1648+await expect(store.lookup("interaction:1")).resolves.toEqual({ ok: "from-wal" });
1649+});
1650+});
1651+15441652it("imports the legacy plugin install index JSON into shared state", async () => {
15451653const root = await makeTempRoot();
15461654const sourcePath = path.join(root, "plugins", "installs.json");
@@ -2321,6 +2429,103 @@ describe("doctor legacy state migrations", () => {
23212429});
23222430});
232324312432+it("archives task rollback journals with the legacy databases", async () => {
2433+const root = await makeTempRoot();
2434+const { taskRunsPath, flowRunsPath } = writeLegacyTaskStateSidecars(root);
2435+const taskJournalPath = `${taskRunsPath}-journal`;
2436+const flowJournalPath = `${flowRunsPath}-journal`;
2437+fs.writeFileSync(taskJournalPath, "");
2438+fs.writeFileSync(flowJournalPath, "");
2439+2440+const result = await autoMigrateLegacyTaskStateSidecars({
2441+env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
2442+});
2443+2444+expect(result.warnings).toStrictEqual([]);
2445+for (const sourcePath of [taskRunsPath, flowRunsPath]) {
2446+expect(fs.existsSync(sourcePath)).toBe(false);
2447+expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
2448+expect(fs.existsSync(`${sourcePath}-journal`)).toBe(false);
2449+expect(fs.existsSync(`${sourcePath}-journal.migrated`)).toBe(true);
2450+}
2451+});
2452+2453+it("reports pending task and flow sidecar archive cleanup", async () => {
2454+const root = await makeTempRoot();
2455+const taskRunsPath = path.join(root, "tasks", "runs.sqlite");
2456+const flowRunsPath = path.join(root, "flows", "registry.sqlite");
2457+for (const sourcePath of [taskRunsPath, flowRunsPath]) {
2458+fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
2459+fs.writeFileSync(`${sourcePath}.migrated`, "");
2460+fs.writeFileSync(`${sourcePath}-wal`, "");
2461+}
2462+2463+const detected = await detectLegacyStateMigrations({
2464+cfg: {},
2465+env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
2466+});
2467+2468+expect(detected.taskStateSidecars.hasLegacy).toBe(true);
2469+expect(detected.preview).toContain(
2470+`- Task registry sidecar: finish archive cleanup for ${taskRunsPath}`,
2471+);
2472+expect(detected.preview).toContain(
2473+`- Task flow sidecar: finish archive cleanup for ${flowRunsPath}`,
2474+);
2475+});
2476+2477+it("retries task-state archival after a sidecar rename failure", async () => {
2478+const root = await makeTempRoot();
2479+const { taskRunsPath } = writeLegacyTaskStateSidecars(root);
2480+const walPath = `${taskRunsPath}-wal`;
2481+const pendingWalState = writePendingWalSnapshot(taskRunsPath, (db) => {
2482+db.prepare("UPDATE task_runs SET label = ? WHERE task_id = ?").run(
2483+"Pending WAL task",
2484+"legacy-task",
2485+);
2486+});
2487+2488+const rename = failRenameOnce(walPath);
2489+const firstResult = await (async () => {
2490+try {
2491+return await autoMigrateLegacyTaskStateSidecars({
2492+env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
2493+});
2494+} finally {
2495+rename.mockRestore();
2496+}
2497+})();
2498+2499+expect(firstResult.changes).toContain(
2500+"Migrated 1 task registry sidecar row → shared SQLite state",
2501+);
2502+expect(firstResult.warnings).toStrictEqual([
2503+`Failed archiving task registry sidecar ${walPath}: Error: forced archive failure`,
2504+]);
2505+expect(fs.existsSync(taskRunsPath)).toBe(false);
2506+expect(fs.existsSync(`${taskRunsPath}.migrated`)).toBe(true);
2507+expect(fs.existsSync(walPath)).toBe(true);
2508+expect(fs.existsSync(`${walPath}.migrated`)).toBe(false);
2509+2510+resetAutoMigrateLegacyTaskStateSidecarsForTest();
2511+const retryResult = await autoMigrateLegacyTaskStateSidecars({
2512+env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
2513+});
2514+2515+expect(retryResult.warnings).toStrictEqual([]);
2516+expect(retryResult.changes).toStrictEqual([
2517+`Archived task registry sidecar legacy source → ${taskRunsPath}.migrated`,
2518+]);
2519+expect(fs.existsSync(walPath)).toBe(false);
2520+expect(fs.readFileSync(`${walPath}.migrated`)).toEqual(pendingWalState);
2521+2522+await withStateDir(root, async () => {
2523+expect(loadTaskRegistryStateFromSqlite().tasks.get("legacy-task")).toMatchObject({
2524+label: "Pending WAL task",
2525+});
2526+});
2527+});
2528+23242529it("skips orphan task delivery sidecar rows while importing valid task rows", async () => {
23252530const root = await makeTempRoot();
23262531const { taskRunsPath } = writeLegacyTaskStateSidecars(root);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。