manager.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  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. constructor(private readonly projectRoot: string) {}
  8. private static defaultAuthorIntent(language: "zh" | "en"): string {
  9. return language === "zh"
  10. ? "# 作者意图\n\n(在这里描述这本书的长期创作方向。)\n"
  11. : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
  12. }
  13. private static defaultCurrentFocus(language: "zh" | "en"): string {
  14. return language === "zh"
  15. ? "# 当前聚焦\n\n## 当前重点\n\n(描述接下来 1-3 章最需要优先推进的内容。)\n"
  16. : "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
  17. }
  18. async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
  19. const language = await this.resolveControlDocumentLanguage(bookId);
  20. await this.ensureControlDocumentsAt(this.bookDir(bookId), language, authorIntent);
  21. }
  22. async ensureControlDocumentsAt(
  23. bookDir: string,
  24. language: "zh" | "en",
  25. authorIntent?: string,
  26. ): Promise<void> {
  27. const storyDir = join(bookDir, "story");
  28. const runtimeDir = join(storyDir, "runtime");
  29. await mkdir(storyDir, { recursive: true });
  30. await mkdir(runtimeDir, { recursive: true });
  31. await this.writeIfMissing(
  32. join(storyDir, "author_intent.md"),
  33. authorIntent?.trim()
  34. ? authorIntent.trimEnd() + "\n"
  35. : StateManager.defaultAuthorIntent(language),
  36. );
  37. await this.writeIfMissing(
  38. join(storyDir, "current_focus.md"),
  39. StateManager.defaultCurrentFocus(language),
  40. );
  41. }
  42. async loadControlDocuments(bookId: string): Promise<{
  43. authorIntent: string;
  44. currentFocus: string;
  45. runtimeDir: string;
  46. }> {
  47. await this.ensureControlDocuments(bookId);
  48. const storyDir = join(this.bookDir(bookId), "story");
  49. const runtimeDir = join(storyDir, "runtime");
  50. const [authorIntent, currentFocus] = await Promise.all([
  51. readFile(join(storyDir, "author_intent.md"), "utf-8"),
  52. readFile(join(storyDir, "current_focus.md"), "utf-8"),
  53. ]);
  54. return { authorIntent, currentFocus, runtimeDir };
  55. }
  56. private async resolveControlDocumentLanguage(bookId: string): Promise<"zh" | "en"> {
  57. try {
  58. const raw = await readFile(join(this.bookDir(bookId), "book.json"), "utf-8");
  59. const parsed = JSON.parse(raw) as { language?: unknown };
  60. return parsed.language === "zh" ? "zh" : "en";
  61. } catch {
  62. return "en";
  63. }
  64. }
  65. async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
  66. await mkdir(this.bookDir(bookId), { recursive: true });
  67. const lockPath = join(this.bookDir(bookId), ".write.lock");
  68. try {
  69. const handle = await open(lockPath, "wx");
  70. try {
  71. await handle.writeFile(`pid:${process.pid} ts:${Date.now()}`, "utf-8");
  72. } catch (error) {
  73. await handle.close().catch(() => undefined);
  74. await unlink(lockPath).catch(() => undefined);
  75. throw error;
  76. }
  77. await handle.close();
  78. } catch (e) {
  79. const code = (e as NodeJS.ErrnoException | undefined)?.code;
  80. if (code === "EEXIST") {
  81. const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
  82. const lockPid = this.extractLockPid(lockData);
  83. if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
  84. await unlink(lockPath).catch(() => undefined);
  85. return this.acquireBookLock(bookId);
  86. }
  87. throw new Error(
  88. `Book "${bookId}" is locked by another process (${lockData}). ` +
  89. `If this is stale, delete ${lockPath}`,
  90. );
  91. }
  92. throw e;
  93. }
  94. return async () => {
  95. try {
  96. await unlink(lockPath);
  97. } catch {
  98. // ignore
  99. }
  100. };
  101. }
  102. private extractLockPid(lockData: string): number | undefined {
  103. const match = lockData.match(/pid:(\d+)/);
  104. if (!match) return undefined;
  105. const pid = Number.parseInt(match[1] ?? "", 10);
  106. return Number.isInteger(pid) && pid > 0 ? pid : undefined;
  107. }
  108. private isProcessAlive(pid: number): boolean {
  109. try {
  110. process.kill(pid, 0);
  111. return true;
  112. } catch (error) {
  113. const code = (error as NodeJS.ErrnoException | undefined)?.code;
  114. if (code === "ESRCH") {
  115. return false;
  116. }
  117. return true;
  118. }
  119. }
  120. get booksDir(): string {
  121. return join(this.projectRoot, "books");
  122. }
  123. bookDir(bookId: string): string {
  124. return join(this.booksDir, bookId);
  125. }
  126. stateDir(bookId: string): string {
  127. return join(this.bookDir(bookId), "story", "state");
  128. }
  129. async loadProjectConfig(): Promise<Record<string, unknown>> {
  130. const configPath = join(this.projectRoot, "inkos.json");
  131. const raw = await readFile(configPath, "utf-8");
  132. return JSON.parse(raw);
  133. }
  134. async saveProjectConfig(config: Record<string, unknown>): Promise<void> {
  135. const configPath = join(this.projectRoot, "inkos.json");
  136. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  137. }
  138. async loadBookConfig(bookId: string): Promise<BookConfig> {
  139. const configPath = join(this.bookDir(bookId), "book.json");
  140. const raw = await readFile(configPath, "utf-8");
  141. if (!raw.trim()) {
  142. throw new Error(`book.json is empty for book "${bookId}"`);
  143. }
  144. return JSON.parse(raw) as BookConfig;
  145. }
  146. async saveBookConfig(bookId: string, config: BookConfig): Promise<void> {
  147. await this.saveBookConfigAt(this.bookDir(bookId), config);
  148. }
  149. async saveBookConfigAt(bookDir: string, config: BookConfig): Promise<void> {
  150. await mkdir(bookDir, { recursive: true });
  151. await writeFile(
  152. join(bookDir, "book.json"),
  153. JSON.stringify(config, null, 2),
  154. "utf-8",
  155. );
  156. }
  157. async ensureRuntimeState(bookId: string, fallbackChapter = 0): Promise<void> {
  158. await bootstrapStructuredStateFromMarkdown({
  159. bookDir: this.bookDir(bookId),
  160. fallbackChapter,
  161. });
  162. }
  163. async listBooks(): Promise<ReadonlyArray<string>> {
  164. try {
  165. const entries = await readdir(this.booksDir);
  166. const bookIds: string[] = [];
  167. for (const entry of entries) {
  168. const bookJsonPath = join(this.booksDir, entry, "book.json");
  169. try {
  170. await stat(bookJsonPath);
  171. bookIds.push(entry);
  172. } catch {
  173. // not a book directory
  174. }
  175. }
  176. return bookIds;
  177. } catch {
  178. return [];
  179. }
  180. }
  181. async getNextChapterNumber(bookId: string): Promise<number> {
  182. const index = await this.loadChapterIndex(bookId);
  183. const indexedChapter = index.length > 0
  184. ? Math.max(...index.map((ch) => ch.number))
  185. : 0;
  186. const runtimeState = await bootstrapStructuredStateFromMarkdown({
  187. bookDir: this.bookDir(bookId),
  188. fallbackChapter: indexedChapter,
  189. });
  190. const durableChapter = await resolveDurableStoryProgress({
  191. bookDir: this.bookDir(bookId),
  192. fallbackChapter: indexedChapter,
  193. });
  194. return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
  195. }
  196. async getPersistedChapterCount(bookId: string): Promise<number> {
  197. const chaptersDir = join(this.bookDir(bookId), "chapters");
  198. const chapterNumbers = new Set<number>();
  199. try {
  200. const files = await readdir(chaptersDir);
  201. for (const file of files) {
  202. const match = file.match(/^(\d+)_.*\.md$/);
  203. if (!match) continue;
  204. chapterNumbers.add(parseInt(match[1]!, 10));
  205. }
  206. } catch {
  207. return 0;
  208. }
  209. return chapterNumbers.size;
  210. }
  211. async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
  212. const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
  213. try {
  214. const raw = await readFile(indexPath, "utf-8");
  215. return JSON.parse(raw);
  216. } catch {
  217. return [];
  218. }
  219. }
  220. async saveChapterIndex(
  221. bookId: string,
  222. index: ReadonlyArray<ChapterMeta>,
  223. ): Promise<void> {
  224. await this.saveChapterIndexAt(this.bookDir(bookId), index);
  225. }
  226. async saveChapterIndexAt(
  227. bookDir: string,
  228. index: ReadonlyArray<ChapterMeta>,
  229. ): Promise<void> {
  230. const chaptersDir = join(bookDir, "chapters");
  231. await mkdir(chaptersDir, { recursive: true });
  232. await writeFile(
  233. join(chaptersDir, "index.json"),
  234. JSON.stringify(index, null, 2),
  235. "utf-8",
  236. );
  237. }
  238. async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
  239. await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
  240. }
  241. async snapshotStateAt(bookDir: string, chapterNumber: number): Promise<void> {
  242. const storyDir = join(bookDir, "story");
  243. const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
  244. await mkdir(snapshotDir, { recursive: true });
  245. const files = [
  246. "current_state.md", "particle_ledger.md", "pending_hooks.md",
  247. "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  248. ];
  249. await Promise.all(
  250. files.map(async (f) => {
  251. try {
  252. const content = await readFile(join(storyDir, f), "utf-8");
  253. await writeFile(join(snapshotDir, f), content, "utf-8");
  254. } catch {
  255. // file doesn't exist yet
  256. }
  257. }),
  258. );
  259. const stateDir = join(bookDir, "story", "state");
  260. const snapshotStateDir = join(snapshotDir, "state");
  261. try {
  262. const stateFiles = await readdir(stateDir);
  263. if (stateFiles.length > 0) {
  264. await mkdir(snapshotStateDir, { recursive: true });
  265. await Promise.all(
  266. stateFiles.map(async (fileName) => {
  267. const content = await readFile(join(stateDir, fileName), "utf-8");
  268. await writeFile(join(snapshotStateDir, fileName), content, "utf-8");
  269. }),
  270. );
  271. }
  272. } catch {
  273. // state directory missing — skip
  274. }
  275. }
  276. async isCompleteBookDirectory(bookDir: string): Promise<boolean> {
  277. const requiredPaths = [
  278. join(bookDir, "book.json"),
  279. join(bookDir, "story", "story_bible.md"),
  280. join(bookDir, "story", "volume_outline.md"),
  281. join(bookDir, "story", "book_rules.md"),
  282. join(bookDir, "story", "current_state.md"),
  283. join(bookDir, "story", "pending_hooks.md"),
  284. join(bookDir, "chapters", "index.json"),
  285. ];
  286. for (const requiredPath of requiredPaths) {
  287. try {
  288. await stat(requiredPath);
  289. } catch {
  290. return false;
  291. }
  292. }
  293. return true;
  294. }
  295. async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
  296. const storyDir = join(this.bookDir(bookId), "story");
  297. const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
  298. const files = [
  299. "current_state.md", "particle_ledger.md", "pending_hooks.md",
  300. "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  301. ];
  302. try {
  303. // current_state.md and pending_hooks.md are required;
  304. // particle_ledger.md is optional (numericalSystem=false genres don't have it)
  305. // the rest are optional (may not exist in older snapshots)
  306. const requiredFiles = ["current_state.md", "pending_hooks.md"];
  307. const optionalFiles = files.filter((f) => !requiredFiles.includes(f));
  308. await Promise.all(
  309. requiredFiles.map(async (f) => {
  310. const content = await readFile(join(snapshotDir, f), "utf-8");
  311. await writeFile(join(storyDir, f), content, "utf-8");
  312. }),
  313. );
  314. await Promise.all(
  315. optionalFiles.map(async (f) => {
  316. try {
  317. const content = await readFile(join(snapshotDir, f), "utf-8");
  318. await writeFile(join(storyDir, f), content, "utf-8");
  319. } catch {
  320. // Optional file missing — skip
  321. }
  322. }),
  323. );
  324. const stateDir = this.stateDir(bookId);
  325. let restoredStructuredState = false;
  326. try {
  327. const snapshotStateDir = join(snapshotDir, "state");
  328. const stateFiles = await readdir(snapshotStateDir);
  329. if (stateFiles.length > 0) {
  330. restoredStructuredState = true;
  331. await mkdir(stateDir, { recursive: true });
  332. await Promise.all(
  333. stateFiles.map(async (fileName) => {
  334. const content = await readFile(join(snapshotStateDir, fileName), "utf-8");
  335. await writeFile(join(stateDir, fileName), content, "utf-8");
  336. }),
  337. );
  338. }
  339. } catch {
  340. // snapshot structured state missing — skip
  341. }
  342. if (!restoredStructuredState) {
  343. await rm(stateDir, { recursive: true, force: true });
  344. }
  345. return true;
  346. } catch {
  347. return false;
  348. }
  349. }
  350. private async writeIfMissing(path: string, content: string): Promise<void> {
  351. try {
  352. await stat(path);
  353. } catch {
  354. await writeFile(path, content, "utf-8");
  355. }
  356. }
  357. }