Răsfoiți Sursa

Merge pull request #143 from 185259536/fix/rewrite-rollback-target

fix rewrite targeting after chapter rollback
Junxian Ma 7 luni în urmă
părinte
comite
8d28e364a3

+ 41 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -639,6 +639,46 @@ describe("CLI integration", () => {
       expect(`${stdout}\n${stderr}`).toContain("legacy format");
       expect(`${stdout}\n${stderr}`).toContain("legacy format");
     });
     });
 
 
+    it("fails rewrite before deleting chapters when the rollback snapshot is missing", async () => {
+      const bookId = "rewrite-missing-snapshot";
+      const bookDir = join(projectDir, "books", bookId);
+      const storyDir = join(bookDir, "story");
+      const chaptersDir = join(bookDir, "chapters");
+
+      await mkdir(chaptersDir, { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Rewrite Missing Snapshot",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          createdAt: "2026-03-22T00:00:00.000Z",
+          updatedAt: "2026-03-22T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
+      await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
+      await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
+      await writeFile(join(chaptersDir, "index.json"), JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+        { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+      ], null, 2), "utf-8");
+
+      const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
+        env: failingLlmEnv,
+      });
+      expect(exitCode).not.toBe(0);
+      expect(`${stdout}\n${stderr}`).toContain("missing snapshot for chapter 1");
+      await expect(readFile(join(chaptersDir, "0002_ch2.md"), "utf-8")).resolves.toContain("Content 2");
+    });
+
     it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
     it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
       const state = new StateManager(projectDir);
       const state = new StateManager(projectDir);
       const bookId = "rewrite-cli";
       const bookId = "rewrite-cli";
@@ -699,6 +739,7 @@ describe("CLI integration", () => {
       });
       });
       expect(exitCode).not.toBe(0);
       expect(exitCode).not.toBe(0);
       expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
       expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
+      expect(`${stdout}\n${stderr}`).not.toContain("resolved to 3");
 
 
       const next = await state.getNextChapterNumber(bookId);
       const next = await state.getNextChapterNumber(bookId);
       expect(next).toBe(2);
       expect(next).toBe(2);

+ 14 - 6
packages/cli/src/commands/write.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { Command } from "commander";
 import { PipelineRunner, StateManager } from "@actalk/inkos-core";
 import { PipelineRunner, StateManager } from "@actalk/inkos-core";
-import { readdir, unlink } from "node:fs/promises";
+import { readdir, stat, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { createInterface } from "node:readline";
 import { createInterface } from "node:readline";
 import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js";
 import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js";
@@ -126,6 +126,11 @@ writeCommand
       const state = new StateManager(root);
       const state = new StateManager(root);
       const bookDir = state.bookDir(bookId);
       const bookDir = state.bookDir(bookId);
       const chaptersDir = join(bookDir, "chapters");
       const chaptersDir = join(bookDir, "chapters");
+      const restoreFrom = chapter - 1;
+      const restoreSnapshotDir = join(bookDir, "story", "snapshots", String(restoreFrom));
+      await stat(restoreSnapshotDir).catch(() => {
+        throw new Error(`Cannot rewrite chapter ${chapter}: missing snapshot for chapter ${restoreFrom}`);
+      });
       const migrationHint = await getLegacyMigrationHint(root, bookId);
       const migrationHint = await getLegacyMigrationHint(root, bookId);
       if (migrationHint && !opts.json) {
       if (migrationHint && !opts.json) {
         log(`[migration] ${migrationHint}`);
         log(`[migration] ${migrationHint}`);
@@ -156,12 +161,15 @@ writeCommand
       }
       }
 
 
       // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook)
       // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook)
-      const restoreFrom = chapter - 1;
       const restored = await state.restoreState(bookId, restoreFrom);
       const restored = await state.restoreState(bookId, restoreFrom);
-      if (restored) {
-        if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
-      } else {
-        if (!opts.json) log(`Warning: no snapshot for chapter ${restoreFrom}. Using current state.`);
+      if (!restored) {
+        throw new Error(`Cannot rewrite chapter ${chapter}: failed to restore snapshot for chapter ${restoreFrom}`);
+      }
+      if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
+
+      const nextChapter = await state.getNextChapterNumber(bookId);
+      if (nextChapter !== chapter) {
+        throw new Error(`Cannot rewrite chapter ${chapter}: expected next chapter to be ${chapter}, but resolved to ${nextChapter}`);
       }
       }
 
 
       if (!opts.json) log(`Regenerating chapter ${chapter}...`);
       if (!opts.json) log(`Regenerating chapter ${chapter}...`);

+ 16 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -472,6 +472,22 @@ describe("StateManager", () => {
       expect(ledger).toBe("# Ledger at ch1");
       expect(ledger).toBe("# Ledger at ch1");
     });
     });
 
 
+    it("removes live optional truth files that are absent from the snapshot", async () => {
+      const storyDir = join(manager.bookDir(bookId), "story");
+      await rm(join(storyDir, "particle_ledger.md"));
+      await manager.snapshotState(bookId, 1);
+
+      await writeFile(
+        join(storyDir, "particle_ledger.md"),
+        "# Ledger added after snapshot",
+        "utf-8",
+      );
+
+      const restored = await manager.restoreState(bookId, 1);
+      expect(restored).toBe(true);
+      await expect(stat(join(storyDir, "particle_ledger.md"))).rejects.toThrow();
+    });
+
     it("restores structured runtime state files from snapshot/state", async () => {
     it("restores structured runtime state files from snapshot/state", async () => {
       const stateDir = manager.stateDir(bookId);
       const stateDir = manager.stateDir(bookId);
       await mkdir(stateDir, { recursive: true });
       await mkdir(stateDir, { recursive: true });

+ 3 - 2
packages/core/src/state/manager.ts

@@ -354,11 +354,12 @@ export class StateManager {
 
 
       await Promise.all(
       await Promise.all(
         optionalFiles.map(async (f) => {
         optionalFiles.map(async (f) => {
+          const targetPath = join(storyDir, f);
           try {
           try {
             const content = await readFile(join(snapshotDir, f), "utf-8");
             const content = await readFile(join(snapshotDir, f), "utf-8");
-            await writeFile(join(storyDir, f), content, "utf-8");
+            await writeFile(targetPath, content, "utf-8");
           } catch {
           } catch {
-            // Optional file missing — skip
+            await rm(targetPath, { force: true });
           }
           }
         }),
         }),
       );
       );