manager.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import type { BookConfig } from "../models/book.js";
  4. import type { ChapterMeta } from "../models/chapter.js";
  5. import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
  6. export class StateManager {
  7. /** Books actively being written by this process — used for same-process stale lock detection. */
  8. private readonly activeWrites = new Set<string>();
  9. constructor(private readonly projectRoot: string) {}
  10. private static defaultAuthorIntent(language: "zh" | "en"): string {
  11. return language === "zh"
  12. ? "# 作者意图\n\n(在这里描述这本书的长期创作方向。)\n"
  13. : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
  14. }
  15. private static defaultCurrentFocus(language: "zh" | "en"): string {
  16. return language === "zh"
  17. ? "# 当前聚焦\n\n## 当前重点\n\n(描述接下来 1-3 章最需要优先推进的内容。)\n"
  18. : "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
  19. }
  20. async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
  21. const language = await this.resolveControlDocumentLanguage(bookId);
  22. await this.ensureControlDocumentsAt(this.bookDir(bookId), language, authorIntent);
  23. }
  24. async ensureControlDocumentsAt(
  25. bookDir: string,
  26. language: "zh" | "en",
  27. authorIntent?: string,
  28. ): Promise<void> {
  29. const storyDir = join(bookDir, "story");
  30. const runtimeDir = join(storyDir, "runtime");
  31. await mkdir(storyDir, { recursive: true });
  32. await mkdir(runtimeDir, { recursive: true });
  33. await this.writeIfMissing(
  34. join(storyDir, "author_intent.md"),
  35. authorIntent?.trim()
  36. ? authorIntent.trimEnd() + "\n"
  37. : StateManager.defaultAuthorIntent(language),
  38. );
  39. await this.writeIfMissing(
  40. join(storyDir, "current_focus.md"),
  41. StateManager.defaultCurrentFocus(language),
  42. );
  43. }
  44. async loadControlDocuments(bookId: string): Promise<{
  45. authorIntent: string;
  46. currentFocus: string;
  47. runtimeDir: string;
  48. }> {
  49. await this.ensureControlDocuments(bookId);
  50. const storyDir = join(this.bookDir(bookId), "story");
  51. const runtimeDir = join(storyDir, "runtime");
  52. const [authorIntent, currentFocus] = await Promise.all([
  53. readFile(join(storyDir, "author_intent.md"), "utf-8"),
  54. readFile(join(storyDir, "current_focus.md"), "utf-8"),
  55. ]);
  56. return { authorIntent, currentFocus, runtimeDir };
  57. }
  58. private async resolveControlDocumentLanguage(bookId: string): Promise<"zh" | "en"> {
  59. try {
  60. const raw = await readFile(join(this.bookDir(bookId), "book.json"), "utf-8");
  61. const parsed = JSON.parse(raw) as { language?: unknown };
  62. return parsed.language === "zh" ? "zh" : "en";
  63. } catch {
  64. return "en";
  65. }
  66. }
  67. async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
  68. await mkdir(this.bookDir(bookId), { recursive: true });
  69. const lockPath = join(this.bookDir(bookId), ".write.lock");
  70. try {
  71. const handle = await open(lockPath, "wx");
  72. try {
  73. await handle.writeFile(`pid:${process.pid} ts:${Date.now()}`, "utf-8");
  74. } catch (error) {
  75. await handle.close().catch(() => undefined);
  76. await unlink(lockPath).catch(() => undefined);
  77. throw error;
  78. }
  79. await handle.close();
  80. } catch (e) {
  81. const code = (e as NodeJS.ErrnoException | undefined)?.code;
  82. if (code === "EEXIST") {
  83. const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
  84. const lockPid = this.extractLockPid(lockData);
  85. const isStale =
  86. (lockPid !== undefined && !this.isProcessAlive(lockPid)) ||
  87. (lockPid === process.pid && !this.activeWrites.has(bookId));
  88. if (isStale) {
  89. await unlink(lockPath).catch(() => undefined);
  90. return this.acquireBookLock(bookId);
  91. }
  92. throw new Error(
  93. `Book "${bookId}" is locked by another process (${lockData}). ` +
  94. `If this is stale, delete ${lockPath}`,
  95. );
  96. }
  97. throw e;
  98. }
  99. this.activeWrites.add(bookId);
  100. return async () => {
  101. this.activeWrites.delete(bookId);
  102. try {
  103. await unlink(lockPath);
  104. } catch {
  105. // ignore
  106. }
  107. };
  108. }
  109. private extractLockPid(lockData: string): number | undefined {
  110. const match = lockData.match(/pid:(\d+)/);
  111. if (!match) return undefined;
  112. const pid = Number.parseInt(match[1] ?? "", 10);
  113. return Number.isInteger(pid) && pid > 0 ? pid : undefined;
  114. }
  115. private isProcessAlive(pid: number): boolean {
  116. try {
  117. process.kill(pid, 0);
  118. return true;
  119. } catch (error) {
  120. const code = (error as NodeJS.ErrnoException | undefined)?.code;
  121. if (code === "ESRCH") {
  122. return false;
  123. }
  124. return true;
  125. }
  126. }
  127. get booksDir(): string {
  128. return join(this.projectRoot, "books");
  129. }
  130. bookDir(bookId: string): string {
  131. return join(this.booksDir, bookId);
  132. }
  133. stateDir(bookId: string): string {
  134. return join(this.bookDir(bookId), "story", "state");
  135. }
  136. async loadProjectConfig(): Promise<Record<string, unknown>> {
  137. const configPath = join(this.projectRoot, "inkos.json");
  138. const raw = await readFile(configPath, "utf-8");
  139. return JSON.parse(raw);
  140. }
  141. async saveProjectConfig(config: Record<string, unknown>): Promise<void> {
  142. const configPath = join(this.projectRoot, "inkos.json");
  143. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  144. }
  145. async loadBookConfig(bookId: string): Promise<BookConfig> {
  146. const configPath = join(this.bookDir(bookId), "book.json");
  147. const raw = await readFile(configPath, "utf-8");
  148. if (!raw.trim()) {
  149. throw new Error(`book.json is empty for book "${bookId}"`);
  150. }
  151. return JSON.parse(raw) as BookConfig;
  152. }
  153. async saveBookConfig(bookId: string, config: BookConfig): Promise<void> {
  154. await this.saveBookConfigAt(this.bookDir(bookId), config);
  155. }
  156. async saveBookConfigAt(bookDir: string, config: BookConfig): Promise<void> {
  157. await mkdir(bookDir, { recursive: true });
  158. await writeFile(
  159. join(bookDir, "book.json"),
  160. JSON.stringify(config, null, 2),
  161. "utf-8",
  162. );
  163. }
  164. async ensureRuntimeState(bookId: string, fallbackChapter = 0): Promise<void> {
  165. await bootstrapStructuredStateFromMarkdown({
  166. bookDir: this.bookDir(bookId),
  167. fallbackChapter,
  168. });
  169. }
  170. async listBooks(): Promise<ReadonlyArray<string>> {
  171. try {
  172. const entries = await readdir(this.booksDir);
  173. const bookIds: string[] = [];
  174. for (const entry of entries) {
  175. const bookJsonPath = join(this.booksDir, entry, "book.json");
  176. try {
  177. await stat(bookJsonPath);
  178. bookIds.push(entry);
  179. } catch {
  180. // not a book directory
  181. }
  182. }
  183. return bookIds;
  184. } catch {
  185. return [];
  186. }
  187. }
  188. async getNextChapterNumber(bookId: string): Promise<number> {
  189. const durableChapter = await resolveDurableStoryProgress({
  190. bookDir: this.bookDir(bookId),
  191. });
  192. // Ensure structured state is bootstrapped (side-effect: creates missing
  193. // JSON files), but do NOT trust its chapter number for progress — only
  194. // the contiguous durable artifact chain is authoritative.
  195. await bootstrapStructuredStateFromMarkdown({
  196. bookDir: this.bookDir(bookId),
  197. fallbackChapter: durableChapter,
  198. });
  199. return durableChapter + 1;
  200. }
  201. async getPersistedChapterCount(bookId: string): Promise<number> {
  202. const chaptersDir = join(this.bookDir(bookId), "chapters");
  203. const chapterNumbers = new Set<number>();
  204. try {
  205. const files = await readdir(chaptersDir);
  206. for (const file of files) {
  207. const match = file.match(/^(\d+)_.*\.md$/);
  208. if (!match) continue;
  209. chapterNumbers.add(parseInt(match[1]!, 10));
  210. }
  211. } catch {
  212. return 0;
  213. }
  214. return chapterNumbers.size;
  215. }
  216. async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
  217. const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
  218. try {
  219. const raw = await readFile(indexPath, "utf-8");
  220. return JSON.parse(raw);
  221. } catch {
  222. return [];
  223. }
  224. }
  225. async saveChapterIndex(
  226. bookId: string,
  227. index: ReadonlyArray<ChapterMeta>,
  228. ): Promise<void> {
  229. await this.saveChapterIndexAt(this.bookDir(bookId), index);
  230. }
  231. async saveChapterIndexAt(
  232. bookDir: string,
  233. index: ReadonlyArray<ChapterMeta>,
  234. ): Promise<void> {
  235. const chaptersDir = join(bookDir, "chapters");
  236. await mkdir(chaptersDir, { recursive: true });
  237. await writeFile(
  238. join(chaptersDir, "index.json"),
  239. JSON.stringify(index, null, 2),
  240. "utf-8",
  241. );
  242. }
  243. async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
  244. await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
  245. }
  246. async snapshotStateAt(bookDir: string, chapterNumber: number): Promise<void> {
  247. const storyDir = join(bookDir, "story");
  248. const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
  249. await mkdir(snapshotDir, { recursive: true });
  250. const files = [
  251. "current_state.md", "particle_ledger.md", "pending_hooks.md",
  252. "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  253. ];
  254. await Promise.all(
  255. files.map(async (f) => {
  256. try {
  257. const content = await readFile(join(storyDir, f), "utf-8");
  258. await writeFile(join(snapshotDir, f), content, "utf-8");
  259. } catch {
  260. // file doesn't exist yet
  261. }
  262. }),
  263. );
  264. const stateDir = join(bookDir, "story", "state");
  265. const snapshotStateDir = join(snapshotDir, "state");
  266. try {
  267. const stateFiles = await readdir(stateDir);
  268. if (stateFiles.length > 0) {
  269. await mkdir(snapshotStateDir, { recursive: true });
  270. await Promise.all(
  271. stateFiles.map(async (fileName) => {
  272. const content = await readFile(join(stateDir, fileName), "utf-8");
  273. await writeFile(join(snapshotStateDir, fileName), content, "utf-8");
  274. }),
  275. );
  276. }
  277. } catch {
  278. // state directory missing — skip
  279. }
  280. }
  281. async isCompleteBookDirectory(bookDir: string): Promise<boolean> {
  282. const requiredPaths = [
  283. join(bookDir, "book.json"),
  284. join(bookDir, "story", "story_bible.md"),
  285. join(bookDir, "story", "volume_outline.md"),
  286. join(bookDir, "story", "book_rules.md"),
  287. join(bookDir, "story", "current_state.md"),
  288. join(bookDir, "story", "pending_hooks.md"),
  289. join(bookDir, "chapters", "index.json"),
  290. ];
  291. for (const requiredPath of requiredPaths) {
  292. try {
  293. await stat(requiredPath);
  294. } catch {
  295. return false;
  296. }
  297. }
  298. return true;
  299. }
  300. async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
  301. const storyDir = join(this.bookDir(bookId), "story");
  302. const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
  303. const files = [
  304. "current_state.md", "particle_ledger.md", "pending_hooks.md",
  305. "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  306. ];
  307. try {
  308. // current_state.md and pending_hooks.md are required;
  309. // particle_ledger.md is optional (numericalSystem=false genres don't have it)
  310. // the rest are optional (may not exist in older snapshots)
  311. const requiredFiles = ["current_state.md", "pending_hooks.md"];
  312. const optionalFiles = files.filter((f) => !requiredFiles.includes(f));
  313. await Promise.all(
  314. requiredFiles.map(async (f) => {
  315. const content = await readFile(join(snapshotDir, f), "utf-8");
  316. await writeFile(join(storyDir, f), content, "utf-8");
  317. }),
  318. );
  319. await Promise.all(
  320. optionalFiles.map(async (f) => {
  321. const targetPath = join(storyDir, f);
  322. try {
  323. const content = await readFile(join(snapshotDir, f), "utf-8");
  324. await writeFile(targetPath, content, "utf-8");
  325. } catch {
  326. await rm(targetPath, { force: true });
  327. }
  328. }),
  329. );
  330. const stateDir = this.stateDir(bookId);
  331. let restoredStructuredState = false;
  332. try {
  333. const snapshotStateDir = join(snapshotDir, "state");
  334. const stateFiles = await readdir(snapshotStateDir);
  335. if (stateFiles.length > 0) {
  336. restoredStructuredState = true;
  337. await mkdir(stateDir, { recursive: true });
  338. await Promise.all(
  339. stateFiles.map(async (fileName) => {
  340. const content = await readFile(join(snapshotStateDir, fileName), "utf-8");
  341. await writeFile(join(stateDir, fileName), content, "utf-8");
  342. }),
  343. );
  344. }
  345. } catch {
  346. // snapshot structured state missing — skip
  347. }
  348. if (!restoredStructuredState) {
  349. await rm(stateDir, { recursive: true, force: true });
  350. }
  351. return true;
  352. } catch {
  353. return false;
  354. }
  355. }
  356. /**
  357. * Roll back state to the snapshot at `targetChapter`, removing all chapters
  358. * after it and their associated files (chapter markdown, snapshots, runtime).
  359. * Used by review reject to undo a bad chapter and everything that followed.
  360. *
  361. * Returns the list of chapter numbers that were discarded.
  362. */
  363. async rollbackToChapter(
  364. bookId: string,
  365. targetChapter: number,
  366. ): Promise<ReadonlyArray<number>> {
  367. const restored = await this.restoreState(bookId, targetChapter);
  368. if (!restored) {
  369. throw new Error(`Cannot restore snapshot for chapter ${targetChapter} in "${bookId}"`);
  370. }
  371. const bookDir = this.bookDir(bookId);
  372. const chaptersDir = join(bookDir, "chapters");
  373. const index = await this.loadChapterIndex(bookId);
  374. const kept: ChapterMeta[] = [];
  375. const discarded: number[] = [];
  376. for (const entry of index) {
  377. if (entry.number <= targetChapter) {
  378. kept.push(entry);
  379. } else {
  380. discarded.push(entry.number);
  381. }
  382. }
  383. // Delete chapter markdown files for discarded chapters
  384. try {
  385. const files = await readdir(chaptersDir);
  386. for (const file of files) {
  387. const match = file.match(/^(\d+)_.*\.md$/);
  388. if (!match) continue;
  389. const num = parseInt(match[1]!, 10);
  390. if (num > targetChapter) {
  391. await unlink(join(chaptersDir, file)).catch(() => {});
  392. }
  393. }
  394. } catch {
  395. // chapters directory missing
  396. }
  397. // Delete snapshots for discarded chapters
  398. const snapshotsDir = join(bookDir, "story", "snapshots");
  399. try {
  400. const snapshots = await readdir(snapshotsDir);
  401. for (const snap of snapshots) {
  402. const num = parseInt(snap, 10);
  403. if (Number.isFinite(num) && num > targetChapter) {
  404. await rm(join(snapshotsDir, snap), { recursive: true, force: true });
  405. }
  406. }
  407. } catch {
  408. // snapshots directory missing
  409. }
  410. // Delete runtime artifacts for discarded chapters
  411. const runtimeDir = join(bookDir, "story", "runtime");
  412. try {
  413. const runtimeFiles = await readdir(runtimeDir);
  414. for (const file of runtimeFiles) {
  415. const match = file.match(/^chapter-(\d+)\./);
  416. if (!match) continue;
  417. const num = parseInt(match[1]!, 10);
  418. if (num > targetChapter) {
  419. await unlink(join(runtimeDir, file)).catch(() => {});
  420. }
  421. }
  422. } catch {
  423. // runtime directory missing
  424. }
  425. // Also check story/drafts/ for discarded chapter files
  426. const draftsDir = join(bookDir, "story", "drafts");
  427. try {
  428. const draftFiles = await readdir(draftsDir);
  429. for (const file of draftFiles) {
  430. const match = file.match(/^(\d+)_.*\.md$/);
  431. if (!match) continue;
  432. const num = parseInt(match[1]!, 10);
  433. if (num > targetChapter) {
  434. await unlink(join(draftsDir, file)).catch(() => {});
  435. }
  436. }
  437. } catch {
  438. // drafts directory missing
  439. }
  440. // Drop any persisted sqlite acceleration index so discarded chapters
  441. // cannot leak back into retrieval after the markdown/state rollback.
  442. await Promise.all([
  443. rm(join(bookDir, "story", "memory.db"), { force: true }),
  444. rm(join(bookDir, "story", "memory.db-shm"), { force: true }),
  445. rm(join(bookDir, "story", "memory.db-wal"), { force: true }),
  446. ]);
  447. await this.saveChapterIndex(bookId, kept);
  448. return discarded;
  449. }
  450. private async writeIfMissing(path: string, content: string): Promise<void> {
  451. try {
  452. await stat(path);
  453. } catch {
  454. await writeFile(path, content, "utf-8");
  455. }
  456. }
  457. }