mirror of
https://github.com/Narcooo/inkos
synced 2026-04-21 14:37:16 +00:00
fix: recover from poisoned runtime state progress
This commit is contained in:
parent
15a5bfc09d
commit
73ac26e886
3 changed files with 401 additions and 76 deletions
|
|
@ -169,6 +169,61 @@ describe("StateManager", () => {
|
|||
const next = await manager.getNextChapterNumber("book-y");
|
||||
expect(next).toBe(2);
|
||||
});
|
||||
|
||||
it("uses durable story progress when chapter index lags behind persisted chapter files", async () => {
|
||||
const bookId = "stale-index-book";
|
||||
const bookDir = manager.bookDir(bookId);
|
||||
const chaptersDir = join(bookDir, "chapters");
|
||||
const storyDir = join(bookDir, "story");
|
||||
await mkdir(chaptersDir, { recursive: true });
|
||||
await mkdir(storyDir, { recursive: true });
|
||||
await Promise.all([
|
||||
manager.saveChapterIndex(bookId, [
|
||||
{
|
||||
number: 1,
|
||||
title: "Ch1",
|
||||
status: "ready-for-review",
|
||||
wordCount: 3000,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
auditIssues: [],
|
||||
lengthWarnings: [],
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Ch2",
|
||||
status: "ready-for-review",
|
||||
wordCount: 3000,
|
||||
createdAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
auditIssues: [],
|
||||
lengthWarnings: [],
|
||||
},
|
||||
]),
|
||||
writeFile(
|
||||
join(chaptersDir, "0003_Lantern_Vault.md"),
|
||||
"# Chapter 3: Lantern Vault\n\nPersisted body.",
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(storyDir, "current_state.md"),
|
||||
[
|
||||
"# Current State",
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"| --- | --- |",
|
||||
"| Current Chapter | 3 |",
|
||||
"| Current Goal | Enter the vault without alerting the wardens |",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
),
|
||||
]);
|
||||
|
||||
const next = await manager.getNextChapterNumber(bookId);
|
||||
|
||||
expect(next).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -654,5 +709,127 @@ describe("StateManager", () => {
|
|||
|
||||
expect(manifest.lastAppliedChapter).toBe(1);
|
||||
});
|
||||
|
||||
it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => {
|
||||
const bookId = "runtime-state-poisoned-book";
|
||||
const storyDir = join(manager.bookDir(bookId), "story");
|
||||
const stateDir = join(storyDir, "state");
|
||||
await mkdir(stateDir, { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(storyDir, "current_state.md"),
|
||||
[
|
||||
"# Current State",
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"| --- | --- |",
|
||||
"| Current Chapter | 2 |",
|
||||
"| Current Goal | Reach the ledger vault |",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(storyDir, "pending_hooks.md"),
|
||||
[
|
||||
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| vault-ledger | 1 | mystery | progressing | 2 | 4 | Ledger trail remains open |",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(storyDir, "chapter_summaries.md"),
|
||||
[
|
||||
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| 1 | Harbor Ash | Lin Yue | Survives the harbor fallout | Debt line opens | vault-ledger seeded | tense | opening |",
|
||||
"| 2 | Lantern Wharf | Lin Yue | Tracks the ledger to the wharf | Goal narrows to the vault | vault-ledger advanced | wary | investigation |",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(join(stateDir, "manifest.json"), JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
language: "en",
|
||||
lastAppliedChapter: 3,
|
||||
projectionVersion: 1,
|
||||
migrationWarnings: [],
|
||||
}, null, 2), "utf-8"),
|
||||
writeFile(join(stateDir, "current_state.json"), JSON.stringify({
|
||||
chapter: 2,
|
||||
facts: [
|
||||
{
|
||||
subject: "protagonist",
|
||||
predicate: "Current Goal",
|
||||
object: "Reach the ledger vault",
|
||||
validFromChapter: 2,
|
||||
validUntilChapter: null,
|
||||
sourceChapter: 2,
|
||||
},
|
||||
],
|
||||
}, null, 2), "utf-8"),
|
||||
writeFile(join(stateDir, "hooks.json"), JSON.stringify({
|
||||
hooks: [
|
||||
{
|
||||
hookId: "vault-ledger",
|
||||
startChapter: 1,
|
||||
type: "mystery",
|
||||
status: "progressing",
|
||||
lastAdvancedChapter: 2,
|
||||
expectedPayoff: "4",
|
||||
notes: "Persisted structured hook state",
|
||||
},
|
||||
],
|
||||
}, null, 2), "utf-8"),
|
||||
writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
|
||||
rows: [
|
||||
{
|
||||
chapter: 1,
|
||||
title: "Harbor Ash",
|
||||
characters: "Lin Yue",
|
||||
events: "Survives the harbor fallout",
|
||||
stateChanges: "Debt line opens",
|
||||
hookActivity: "vault-ledger seeded",
|
||||
mood: "tense",
|
||||
chapterType: "opening",
|
||||
},
|
||||
{
|
||||
chapter: 2,
|
||||
title: "Lantern Wharf",
|
||||
characters: "Lin Yue",
|
||||
events: "Tracks the ledger to the wharf",
|
||||
stateChanges: "Goal narrows to the vault",
|
||||
hookActivity: "vault-ledger advanced",
|
||||
mood: "wary",
|
||||
chapterType: "investigation",
|
||||
},
|
||||
],
|
||||
}, null, 2), "utf-8"),
|
||||
]);
|
||||
|
||||
await manager.ensureRuntimeState(bookId, 2);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(stateDir, "manifest.json"), "utf-8"),
|
||||
) as { lastAppliedChapter: number };
|
||||
const currentState = JSON.parse(
|
||||
await readFile(join(stateDir, "current_state.json"), "utf-8"),
|
||||
) as { chapter: number; facts: Array<{ object: string }> };
|
||||
const hooks = JSON.parse(
|
||||
await readFile(join(stateDir, "hooks.json"), "utf-8"),
|
||||
) as { hooks: Array<{ lastAdvancedChapter: number }> };
|
||||
const summaries = JSON.parse(
|
||||
await readFile(join(stateDir, "chapter_summaries.json"), "utf-8"),
|
||||
) as { rows: Array<{ chapter: number; title: string }> };
|
||||
|
||||
expect(manifest.lastAppliedChapter).toBe(2);
|
||||
expect(currentState.chapter).toBe(2);
|
||||
expect(currentState.facts[0]?.object).toBe("Reach the ledger vault");
|
||||
expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(2);
|
||||
expect(summaries.rows.map((row) => row.chapter)).toEqual([1, 2]);
|
||||
expect(summaries.rows.at(-1)?.title).toBe("Lantern Wharf");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, readdir, stat, unlink, open } from "node:fs
|
|||
import { join } from "node:path";
|
||||
import type { BookConfig } from "../models/book.js";
|
||||
import type { ChapterMeta } from "../models/chapter.js";
|
||||
import { bootstrapStructuredStateFromMarkdown } from "./state-bootstrap.js";
|
||||
import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
|
||||
|
||||
export class StateManager {
|
||||
constructor(private readonly projectRoot: string) {}
|
||||
|
|
@ -195,9 +195,18 @@ export class StateManager {
|
|||
|
||||
async getNextChapterNumber(bookId: string): Promise<number> {
|
||||
const index = await this.loadChapterIndex(bookId);
|
||||
if (index.length === 0) return 1;
|
||||
const maxNum = Math.max(...index.map((ch) => ch.number));
|
||||
return maxNum + 1;
|
||||
const indexedChapter = index.length > 0
|
||||
? Math.max(...index.map((ch) => ch.number))
|
||||
: 0;
|
||||
const runtimeState = await bootstrapStructuredStateFromMarkdown({
|
||||
bookDir: this.bookDir(bookId),
|
||||
fallbackChapter: indexedChapter,
|
||||
});
|
||||
const durableChapter = await resolveDurableStoryProgress({
|
||||
bookDir: this.bookDir(bookId),
|
||||
fallbackChapter: indexedChapter,
|
||||
});
|
||||
return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
|
||||
}
|
||||
|
||||
async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ChapterSummariesStateSchema,
|
||||
|
|
@ -18,6 +18,13 @@ export interface BootstrapStructuredStateResult {
|
|||
readonly manifest: StateManifest;
|
||||
}
|
||||
|
||||
interface MarkdownBootstrapState {
|
||||
readonly summariesState: ChapterSummariesState;
|
||||
readonly hooksState: { readonly hooks: ReadonlyArray<StoredHook> };
|
||||
readonly currentState: CurrentStateState;
|
||||
readonly durableStoryProgress: number;
|
||||
}
|
||||
|
||||
export async function bootstrapStructuredStateFromMarkdown(params: {
|
||||
readonly bookDir: string;
|
||||
readonly fallbackChapter?: number;
|
||||
|
|
@ -35,41 +42,52 @@ export async function bootstrapStructuredStateFromMarkdown(params: {
|
|||
const warnings: string[] = [];
|
||||
const existingManifest = await loadJsonIfValid(manifestPath, StateManifestSchema, warnings, "manifest.json");
|
||||
const language = existingManifest?.language ?? await resolveRuntimeLanguage(params.bookDir);
|
||||
const markdownState = await loadMarkdownBootstrapState({
|
||||
bookDir: params.bookDir,
|
||||
storyDir,
|
||||
fallbackChapter: params.fallbackChapter ?? 0,
|
||||
warnings,
|
||||
});
|
||||
|
||||
const summariesState = await loadOrBootstrapSummaries({
|
||||
storyDir,
|
||||
statePath: summariesPath,
|
||||
createdFiles,
|
||||
warnings,
|
||||
bootstrapState: markdownState.summariesState,
|
||||
});
|
||||
const hooksState = await loadOrBootstrapHooks({
|
||||
storyDir,
|
||||
statePath: hooksPath,
|
||||
createdFiles,
|
||||
warnings,
|
||||
bootstrapState: markdownState.hooksState,
|
||||
});
|
||||
const inferredFallbackChapter = Math.max(
|
||||
params.fallbackChapter ?? 0,
|
||||
maxSummaryChapter(summariesState),
|
||||
maxHookChapter(hooksState.hooks),
|
||||
);
|
||||
const currentState = await loadOrBootstrapCurrentState({
|
||||
storyDir,
|
||||
statePath: currentStatePath,
|
||||
fallbackChapter: inferredFallbackChapter,
|
||||
fallbackChapter: markdownState.durableStoryProgress,
|
||||
createdFiles,
|
||||
warnings,
|
||||
bootstrapState: markdownState.currentState,
|
||||
});
|
||||
const derivedProgress = Math.max(
|
||||
markdownState.durableStoryProgress,
|
||||
currentState.chapter,
|
||||
maxSummaryChapter(summariesState),
|
||||
maxHookChapter(hooksState.hooks),
|
||||
);
|
||||
if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) {
|
||||
appendWarning(
|
||||
warnings,
|
||||
`manifest lastAppliedChapter normalized from ${existingManifest?.lastAppliedChapter ?? 0} to ${derivedProgress}`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = StateManifestSchema.parse({
|
||||
schemaVersion: 2,
|
||||
language,
|
||||
lastAppliedChapter: Math.max(
|
||||
existingManifest?.lastAppliedChapter ?? 0,
|
||||
currentState.chapter,
|
||||
maxSummaryChapter(summariesState),
|
||||
maxHookChapter(hooksState.hooks),
|
||||
),
|
||||
lastAppliedChapter: derivedProgress,
|
||||
projectionVersion: existingManifest?.projectionVersion ?? 1,
|
||||
migrationWarnings: uniqueStrings([
|
||||
...(existingManifest?.migrationWarnings ?? []),
|
||||
|
|
@ -105,29 +123,21 @@ export async function rewriteStructuredStateFromMarkdown(params: {
|
|||
const warnings: string[] = [];
|
||||
const existingManifest = await loadJsonIfValid(manifestPath, StateManifestSchema, warnings, "manifest.json");
|
||||
const language = existingManifest?.language ?? await resolveRuntimeLanguage(params.bookDir);
|
||||
|
||||
const summariesMarkdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
|
||||
const summariesState = ChapterSummariesStateSchema.parse({
|
||||
rows: parseChapterSummariesMarkdown(summariesMarkdown),
|
||||
const markdownState = await loadMarkdownBootstrapState({
|
||||
bookDir: params.bookDir,
|
||||
storyDir,
|
||||
fallbackChapter: params.fallbackChapter ?? 0,
|
||||
warnings,
|
||||
});
|
||||
|
||||
const hooksMarkdown = await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
|
||||
const hooksState = parsePendingHooksStateMarkdown(hooksMarkdown, warnings);
|
||||
|
||||
const inferredFallbackChapter = Math.max(
|
||||
params.fallbackChapter ?? 0,
|
||||
maxSummaryChapter(summariesState),
|
||||
maxHookChapter(hooksState.hooks),
|
||||
);
|
||||
const currentStateMarkdown = await readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => "");
|
||||
const currentState = parseCurrentStateStateMarkdown(currentStateMarkdown, inferredFallbackChapter, warnings);
|
||||
const summariesState = markdownState.summariesState;
|
||||
const hooksState = markdownState.hooksState;
|
||||
const currentState = markdownState.currentState;
|
||||
|
||||
const manifest = StateManifestSchema.parse({
|
||||
schemaVersion: 2,
|
||||
language,
|
||||
lastAppliedChapter: Math.max(
|
||||
existingManifest?.lastAppliedChapter ?? 0,
|
||||
inferredFallbackChapter,
|
||||
markdownState.durableStoryProgress,
|
||||
currentState.chapter,
|
||||
maxSummaryChapter(summariesState),
|
||||
maxHookChapter(hooksState.hooks),
|
||||
|
|
@ -217,21 +227,31 @@ async function loadOrBootstrapCurrentState(params: {
|
|||
readonly fallbackChapter: number;
|
||||
readonly createdFiles: string[];
|
||||
readonly warnings: string[];
|
||||
readonly bootstrapState?: CurrentStateState;
|
||||
readonly forceBootstrapFromMarkdown?: boolean;
|
||||
}): Promise<CurrentStateState> {
|
||||
const existing = await loadJsonIfValid(
|
||||
params.statePath,
|
||||
CurrentStateStateSchema,
|
||||
params.warnings,
|
||||
"current_state.json",
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
if (!params.forceBootstrapFromMarkdown) {
|
||||
const existing = await loadJsonIfValid(
|
||||
params.statePath,
|
||||
CurrentStateStateSchema,
|
||||
params.warnings,
|
||||
"current_state.json",
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = await readFile(join(params.storyDir, "current_state.md"), "utf-8").catch(() => "");
|
||||
const currentState = parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
|
||||
const currentState = params.bootstrapState ?? await loadMarkdownCurrentState({
|
||||
storyDir: params.storyDir,
|
||||
fallbackChapter: params.fallbackChapter,
|
||||
warnings: params.warnings,
|
||||
});
|
||||
const existed = await pathExists(params.statePath);
|
||||
await writeFile(params.statePath, JSON.stringify(currentState, null, 2), "utf-8");
|
||||
params.createdFiles.push("current_state.json");
|
||||
if (!existed) {
|
||||
params.createdFiles.push("current_state.json");
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
|
||||
|
|
@ -240,21 +260,30 @@ async function loadOrBootstrapHooks(params: {
|
|||
readonly statePath: string;
|
||||
readonly createdFiles: string[];
|
||||
readonly warnings: string[];
|
||||
readonly bootstrapState?: { readonly hooks: ReadonlyArray<StoredHook> };
|
||||
readonly forceBootstrapFromMarkdown?: boolean;
|
||||
}) {
|
||||
const existing = await loadJsonIfValid(
|
||||
params.statePath,
|
||||
HooksStateSchema,
|
||||
params.warnings,
|
||||
"hooks.json",
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
if (!params.forceBootstrapFromMarkdown) {
|
||||
const existing = await loadJsonIfValid(
|
||||
params.statePath,
|
||||
HooksStateSchema,
|
||||
params.warnings,
|
||||
"hooks.json",
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = await readFile(join(params.storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
|
||||
const hooksState = parsePendingHooksStateMarkdown(markdown, params.warnings);
|
||||
const hooksState = params.bootstrapState ?? await loadMarkdownHooksState({
|
||||
storyDir: params.storyDir,
|
||||
warnings: params.warnings,
|
||||
});
|
||||
const existed = await pathExists(params.statePath);
|
||||
await writeFile(params.statePath, JSON.stringify(hooksState, null, 2), "utf-8");
|
||||
params.createdFiles.push("hooks.json");
|
||||
if (!existed) {
|
||||
params.createdFiles.push("hooks.json");
|
||||
}
|
||||
return hooksState;
|
||||
}
|
||||
|
||||
|
|
@ -263,31 +292,34 @@ async function loadOrBootstrapSummaries(params: {
|
|||
readonly statePath: string;
|
||||
readonly createdFiles: string[];
|
||||
readonly warnings: string[];
|
||||
readonly bootstrapState?: ChapterSummariesState;
|
||||
readonly forceBootstrapFromMarkdown?: boolean;
|
||||
}): Promise<ChapterSummariesState> {
|
||||
const existing = await loadJsonIfValid(
|
||||
params.statePath,
|
||||
ChapterSummariesStateSchema,
|
||||
params.warnings,
|
||||
"chapter_summaries.json",
|
||||
);
|
||||
if (existing) {
|
||||
// Always deduplicate even when loading from JSON (stale data may have duplicates)
|
||||
const dedupedExisting = deduplicateSummaryRows(existing.rows);
|
||||
if (dedupedExisting.length < existing.rows.length) {
|
||||
const repaired = ChapterSummariesStateSchema.parse({ rows: dedupedExisting });
|
||||
await writeFile(params.statePath, JSON.stringify(repaired, null, 2), "utf-8");
|
||||
return repaired;
|
||||
if (!params.forceBootstrapFromMarkdown) {
|
||||
const existing = await loadJsonIfValid(
|
||||
params.statePath,
|
||||
ChapterSummariesStateSchema,
|
||||
params.warnings,
|
||||
"chapter_summaries.json",
|
||||
);
|
||||
if (existing) {
|
||||
// Always deduplicate even when loading from JSON (stale data may have duplicates)
|
||||
const dedupedExisting = deduplicateSummaryRows(existing.rows);
|
||||
if (dedupedExisting.length < existing.rows.length) {
|
||||
const repaired = ChapterSummariesStateSchema.parse({ rows: dedupedExisting });
|
||||
await writeFile(params.statePath, JSON.stringify(repaired, null, 2), "utf-8");
|
||||
return repaired;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const markdown = await readFile(join(params.storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
|
||||
const rawRows = parseChapterSummariesMarkdown(markdown);
|
||||
const summariesState = ChapterSummariesStateSchema.parse({
|
||||
rows: deduplicateSummaryRows(rawRows),
|
||||
});
|
||||
const summariesState = params.bootstrapState ?? await loadMarkdownSummariesState(params.storyDir);
|
||||
const existed = await pathExists(params.statePath);
|
||||
await writeFile(params.statePath, JSON.stringify(summariesState, null, 2), "utf-8");
|
||||
params.createdFiles.push("chapter_summaries.json");
|
||||
if (!existed) {
|
||||
params.createdFiles.push("chapter_summaries.json");
|
||||
}
|
||||
return summariesState;
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +435,20 @@ async function resolveRuntimeLanguage(bookDir: string): Promise<"zh" | "en"> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function resolveDurableStoryProgress(params: {
|
||||
readonly bookDir: string;
|
||||
readonly fallbackChapter?: number;
|
||||
}): Promise<number> {
|
||||
const storyDir = join(params.bookDir, "story");
|
||||
const state = await loadMarkdownBootstrapState({
|
||||
bookDir: params.bookDir,
|
||||
storyDir,
|
||||
fallbackChapter: params.fallbackChapter ?? 0,
|
||||
warnings: [],
|
||||
});
|
||||
return state.durableStoryProgress;
|
||||
}
|
||||
|
||||
async function loadJsonIfValid<T>(
|
||||
path: string,
|
||||
schema: { parse(value: unknown): T },
|
||||
|
|
@ -421,6 +467,99 @@ async function loadJsonIfValid<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function loadMarkdownBootstrapState(params: {
|
||||
readonly bookDir: string;
|
||||
readonly storyDir: string;
|
||||
readonly fallbackChapter: number;
|
||||
readonly warnings: string[];
|
||||
}): Promise<MarkdownBootstrapState> {
|
||||
const summariesState = await loadMarkdownSummariesState(params.storyDir);
|
||||
const hooksState = await loadMarkdownHooksState({
|
||||
storyDir: params.storyDir,
|
||||
warnings: params.warnings,
|
||||
});
|
||||
const durableArtifactProgress = await maxDurableArtifactChapter(params.bookDir);
|
||||
const inferredFallbackChapter = Math.max(
|
||||
params.fallbackChapter,
|
||||
durableArtifactProgress,
|
||||
maxSummaryChapter(summariesState),
|
||||
maxHookChapter(hooksState.hooks),
|
||||
);
|
||||
const currentState = await loadMarkdownCurrentState({
|
||||
storyDir: params.storyDir,
|
||||
fallbackChapter: inferredFallbackChapter,
|
||||
warnings: params.warnings,
|
||||
});
|
||||
|
||||
return {
|
||||
summariesState,
|
||||
hooksState,
|
||||
currentState,
|
||||
durableStoryProgress: Math.max(
|
||||
inferredFallbackChapter,
|
||||
currentState.chapter,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMarkdownSummariesState(storyDir: string): Promise<ChapterSummariesState> {
|
||||
const markdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
|
||||
const rawRows = parseChapterSummariesMarkdown(markdown);
|
||||
return ChapterSummariesStateSchema.parse({
|
||||
rows: deduplicateSummaryRows(rawRows),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMarkdownHooksState(params: {
|
||||
readonly storyDir: string;
|
||||
readonly warnings: string[];
|
||||
}) {
|
||||
const markdown = await readFile(join(params.storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
|
||||
return parsePendingHooksStateMarkdown(markdown, params.warnings);
|
||||
}
|
||||
|
||||
async function loadMarkdownCurrentState(params: {
|
||||
readonly storyDir: string;
|
||||
readonly fallbackChapter: number;
|
||||
readonly warnings: string[];
|
||||
}): Promise<CurrentStateState> {
|
||||
const markdown = await readFile(join(params.storyDir, "current_state.md"), "utf-8").catch(() => "");
|
||||
return parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
|
||||
}
|
||||
|
||||
async function maxDurableArtifactChapter(bookDir: string): Promise<number> {
|
||||
const chaptersDir = join(bookDir, "chapters");
|
||||
const indexPath = join(chaptersDir, "index.json");
|
||||
const [indexChapter, fileChapter] = await Promise.all([
|
||||
readFile(indexPath, "utf-8")
|
||||
.then((raw) => {
|
||||
const parsed = JSON.parse(raw) as Array<{ number?: unknown }>;
|
||||
return parsed.reduce((max, entry) => (
|
||||
typeof entry?.number === "number"
|
||||
? Math.max(max, entry.number)
|
||||
: max
|
||||
), 0);
|
||||
})
|
||||
.catch(() => 0),
|
||||
readdir(chaptersDir)
|
||||
.then((entries) => entries.reduce((max, entry) => {
|
||||
const match = entry.match(/^(\d+)_/);
|
||||
return match ? Math.max(max, parseInt(match[1]!, 10)) : max;
|
||||
}, 0))
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return Math.max(indexChapter, fileChapter);
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function deduplicateSummaryRows<T extends { chapter: number }>(rows: ReadonlyArray<T>): T[] {
|
||||
const byChapter = new Map<number, T>();
|
||||
for (const row of rows) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue