mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(core): add narrative forecast schema and store
叙事多线推演(issue #342)v1 的数据层: - forecast/schema.ts:Zod schema 定义 NarrativeForecast / ForecastBranch (前提假设、未来章节节拍、人物决策、预计变化、一致性风险、不确定性、 意图匹配度),分支数限定 2-5,superRefine 拒绝重复 branchId; parseForecastModelOutput 解析模型输出(容忍代码围栏/前后缀散文/尾逗号, 非法 JSON 或 schema 不符直接抛错,不落任何文件) - forecast/store.ts:确定性本地存储,产物固定在 story/runtime/narrative-forecasts/<forecastId>/ 下(forecast.json、 comparison.md、selected-branch-plan.md);时钟与 id 工厂可注入, id 冲突自动加后缀;写入前先过 schema 校验,非法数据不产生半成品目录 - 测试:schema 校验边界(分支数/重复 id/越界分数/非法风险类型)、 store 读写往返、损坏文件报错、markStale 不可变更新、路径安全
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FORECAST_MAX_BRANCHES,
|
||||
FORECAST_MIN_BRANCHES,
|
||||
NarrativeForecastSchema,
|
||||
parseForecastModelOutput,
|
||||
} from "../forecast/schema.js";
|
||||
import { makeForecast, makeForecastBranch } from "./helpers/forecast-fixture.js";
|
||||
|
||||
function modelBranches(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const { branchId: _branchId, ...rest } = makeForecastBranch({ title: `分支${index + 1}` });
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
describe("narrative forecast schema", () => {
|
||||
it("accepts a complete forecast", () => {
|
||||
expect(() => NarrativeForecastSchema.parse(makeForecast())).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects fewer than the minimum branch count", () => {
|
||||
const forecast = makeForecast({ branches: [makeForecastBranch()] });
|
||||
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
|
||||
expect(FORECAST_MIN_BRANCHES).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects more than the maximum branch count", () => {
|
||||
const branches = Array.from({ length: 6 }, (_, index) =>
|
||||
makeForecastBranch({ branchId: `branch-${index + 1}` }));
|
||||
expect(() => NarrativeForecastSchema.parse(makeForecast({ branches }))).toThrow();
|
||||
expect(FORECAST_MAX_BRANCHES).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects duplicate branch ids so sibling branches stay isolated", () => {
|
||||
const forecast = makeForecast({
|
||||
branches: [makeForecastBranch(), makeForecastBranch({ title: "重复 id 的分支" })],
|
||||
});
|
||||
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow(/branch-1/);
|
||||
});
|
||||
|
||||
it("rejects an intent alignment score outside 0-100", () => {
|
||||
const forecast = makeForecast({
|
||||
branches: [
|
||||
makeForecastBranch({ intentAlignment: { score: 101, rationale: "越界" } }),
|
||||
makeForecastBranch({ branchId: "branch-2" }),
|
||||
],
|
||||
});
|
||||
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown risk kinds", () => {
|
||||
const forecast = makeForecast({
|
||||
branches: [
|
||||
makeForecastBranch({ risks: [{ kind: "vibes" as never, description: "不合法" }] }),
|
||||
makeForecastBranch({ branchId: "branch-2" }),
|
||||
],
|
||||
});
|
||||
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a branch with no beats", () => {
|
||||
const forecast = makeForecast({
|
||||
branches: [
|
||||
makeForecastBranch({ beats: [] }),
|
||||
makeForecastBranch({ branchId: "branch-2" }),
|
||||
],
|
||||
});
|
||||
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseForecastModelOutput", () => {
|
||||
it("parses a plain JSON object", () => {
|
||||
const output = parseForecastModelOutput(JSON.stringify({ branches: modelBranches(2) }));
|
||||
expect(output.branches).toHaveLength(2);
|
||||
expect(output.branches[0]?.title).toBe("分支1");
|
||||
});
|
||||
|
||||
it("parses JSON wrapped in a code fence", () => {
|
||||
const raw = "```json\n" + JSON.stringify({ branches: modelBranches(3) }) + "\n```";
|
||||
expect(parseForecastModelOutput(raw).branches).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("parses JSON surrounded by prose", () => {
|
||||
const raw = `好的,以下是推演结果:\n${JSON.stringify({ branches: modelBranches(2) })}\n希望有帮助。`;
|
||||
expect(parseForecastModelOutput(raw).branches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("tolerates trailing commas", () => {
|
||||
const raw = JSON.stringify({ branches: modelBranches(2) }).replace(/\]\}$/, "],}");
|
||||
expect(parseForecastModelOutput(raw).branches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("throws a JSON error on unparsable output", () => {
|
||||
expect(() => parseForecastModelOutput("这不是 JSON")).toThrow(/not valid JSON/);
|
||||
});
|
||||
|
||||
it("throws a schema error on the wrong shape", () => {
|
||||
expect(() => parseForecastModelOutput(JSON.stringify({ branches: [{ title: "缺字段" }] })))
|
||||
.toThrow(/schema validation/);
|
||||
});
|
||||
|
||||
it("throws a schema error when the model returns too few branches", () => {
|
||||
expect(() => parseForecastModelOutput(JSON.stringify({ branches: modelBranches(1) })))
|
||||
.toThrow(/schema validation/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { access, mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ForecastStore, assertSafeForecastId } from "../forecast/store.js";
|
||||
import { makeForecast } from "./helpers/forecast-fixture.js";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ForecastStore", () => {
|
||||
let bookDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
bookDir = await mkdtemp(join(tmpdir(), "inkos-forecast-store-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(bookDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("saves forecast.json and comparison.md under story/runtime/narrative-forecasts/<id>/", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
const forecast = makeForecast();
|
||||
|
||||
const saved = await store.save(forecast, "# 对比\n内容");
|
||||
|
||||
const expectedDir = join(bookDir, "story", "runtime", "narrative-forecasts", forecast.forecastId);
|
||||
expect(saved.forecastJsonPath).toBe(join(expectedDir, "forecast.json"));
|
||||
expect(saved.comparisonPath).toBe(join(expectedDir, "comparison.md"));
|
||||
expect(JSON.parse(await readFile(saved.forecastJsonPath, "utf-8")).forecastId).toBe(forecast.forecastId);
|
||||
expect(await readFile(saved.comparisonPath, "utf-8")).toContain("# 对比");
|
||||
});
|
||||
|
||||
it("round-trips a forecast through save and load", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
const forecast = makeForecast();
|
||||
await store.save(forecast, "cmp");
|
||||
|
||||
const loaded = await store.load(forecast.forecastId);
|
||||
|
||||
expect(loaded).toEqual(forecast);
|
||||
});
|
||||
|
||||
it("refuses to save an invalid forecast and leaves no files behind", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
const invalid = { ...makeForecast(), branches: [] };
|
||||
|
||||
await expect(store.save(invalid as never, "cmp")).rejects.toThrow();
|
||||
expect(await exists(join(bookDir, "story", "runtime", "narrative-forecasts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("throws a not-found error with the forecast id when loading a missing forecast", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
await expect(store.load("fc-missing")).rejects.toThrow(/fc-missing/);
|
||||
});
|
||||
|
||||
it("throws when the stored forecast.json is corrupted", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
const dir = join(bookDir, "story", "runtime", "narrative-forecasts", "fc-bad");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "forecast.json"), "{ not json", "utf-8");
|
||||
|
||||
await expect(store.load("fc-bad")).rejects.toThrow(/fc-bad/);
|
||||
});
|
||||
|
||||
it("markStale persists a stale status without mutating the input object", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
const forecast = makeForecast();
|
||||
await store.save(forecast, "cmp");
|
||||
|
||||
const stale = await store.markStale(forecast);
|
||||
|
||||
expect(stale.status).toBe("stale");
|
||||
expect(forecast.status).toBe("active");
|
||||
expect((await store.load(forecast.forecastId)).status).toBe("stale");
|
||||
});
|
||||
|
||||
it("writes selected-branch-plan.md next to forecast.json", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
const forecast = makeForecast();
|
||||
await store.save(forecast, "cmp");
|
||||
|
||||
const planPath = await store.writeSelectedPlan(forecast.forecastId, "# 分支计划");
|
||||
|
||||
expect(planPath).toBe(join(
|
||||
bookDir, "story", "runtime", "narrative-forecasts", forecast.forecastId, "selected-branch-plan.md",
|
||||
));
|
||||
expect(await readFile(planPath, "utf-8")).toContain("# 分支计划");
|
||||
});
|
||||
|
||||
it("derives deterministic forecast ids from the injected clock", async () => {
|
||||
const store = new ForecastStore(bookDir, { now: () => new Date("2026-07-15T08:09:10Z") });
|
||||
expect(await store.allocateForecastId()).toBe("fc-20260715-080910");
|
||||
});
|
||||
|
||||
it("suffixes the forecast id when the directory already exists", async () => {
|
||||
const store = new ForecastStore(bookDir, { now: () => new Date("2026-07-15T08:09:10Z") });
|
||||
await store.save(makeForecast({ forecastId: "fc-20260715-080910" }), "cmp");
|
||||
|
||||
expect(await store.allocateForecastId()).toBe("fc-20260715-080910-2");
|
||||
});
|
||||
|
||||
it("prefers the injected id factory", async () => {
|
||||
const store = new ForecastStore(bookDir, { idFactory: () => "fc-custom" });
|
||||
expect(await store.allocateForecastId()).toBe("fc-custom");
|
||||
});
|
||||
|
||||
it("rejects unsafe forecast ids", () => {
|
||||
expect(() => assertSafeForecastId("../escape")).toThrow();
|
||||
expect(() => assertSafeForecastId("a/b")).toThrow();
|
||||
expect(() => assertSafeForecastId("")).toThrow();
|
||||
expect(assertSafeForecastId("fc-20260715-080910")).toBe("fc-20260715-080910");
|
||||
});
|
||||
|
||||
it("lists saved forecast ids", async () => {
|
||||
const store = new ForecastStore(bookDir);
|
||||
await store.save(makeForecast({ forecastId: "fc-b" }), "cmp");
|
||||
await store.save(makeForecast({ forecastId: "fc-a" }), "cmp");
|
||||
|
||||
expect(await store.list()).toEqual(["fc-a", "fc-b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ForecastBranch, NarrativeForecast } from "../../forecast/schema.js";
|
||||
|
||||
export function makeForecastBranch(overrides: Partial<ForecastBranch> = {}): ForecastBranch {
|
||||
return {
|
||||
branchId: "branch-1",
|
||||
title: "主角接受提议",
|
||||
premise: "假设主角在第13章接受了对手的合作提议。",
|
||||
beats: [
|
||||
{ chapter: 13, summary: "主角签下合作协议,盟友震怒离场。" },
|
||||
{ chapter: 14, summary: "合作暴露主角软肋,对手开始渗透。" },
|
||||
],
|
||||
characterDecisions: [
|
||||
{ character: "主角", decision: "接受提议换取短期资源" },
|
||||
],
|
||||
projectedChanges: {
|
||||
characters: ["主角信誉受损"],
|
||||
relationships: ["主角与盟友决裂"],
|
||||
world: ["东城势力平衡向对手倾斜"],
|
||||
hooks: ["hook-03 提前引爆"],
|
||||
},
|
||||
risks: [
|
||||
{ kind: "character", description: "主角人设锁强调不妥协,接受提议需要强动机铺垫。" },
|
||||
],
|
||||
uncertainties: ["盟友是否会立即反目尚不确定"],
|
||||
intentAlignment: { score: 62, rationale: "偏离作者意图中的复仇主线,但制造了新张力。" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeForecast(overrides: Partial<NarrativeForecast> = {}): NarrativeForecast {
|
||||
return {
|
||||
version: 1,
|
||||
forecastId: "fc-20260101000000",
|
||||
bookId: "demo-book",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
language: "zh",
|
||||
divergence: "主角是否接受对手的合作提议",
|
||||
horizon: 5,
|
||||
baseChapter: 12,
|
||||
contextFingerprint: "abc123",
|
||||
status: "active",
|
||||
branches: [
|
||||
makeForecastBranch(),
|
||||
makeForecastBranch({
|
||||
branchId: "branch-2",
|
||||
title: "主角拒绝提议",
|
||||
premise: "假设主角当场拒绝并公开对手把柄。",
|
||||
intentAlignment: { score: 88, rationale: "延续复仇主线,符合当前聚焦。" },
|
||||
}),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Narrative Forecast (issue #342): non-canonical multi-branch story projection.
|
||||
// A forecast never becomes canon by itself — it is planning material stored
|
||||
// under story/runtime/narrative-forecasts/ and compared by the author.
|
||||
|
||||
export const FORECAST_MIN_BRANCHES = 2;
|
||||
export const FORECAST_MAX_BRANCHES = 5;
|
||||
export const FORECAST_DEFAULT_BRANCHES = 3;
|
||||
export const FORECAST_MIN_HORIZON = 1;
|
||||
export const FORECAST_MAX_HORIZON = 10;
|
||||
export const FORECAST_DEFAULT_HORIZON = 5;
|
||||
|
||||
export const ForecastRiskSchema = z.object({
|
||||
kind: z.enum(["continuity", "causality", "character"]),
|
||||
description: z.string().min(1),
|
||||
});
|
||||
export type ForecastRisk = z.infer<typeof ForecastRiskSchema>;
|
||||
|
||||
export const ForecastBeatSchema = z.object({
|
||||
// Absolute chapter number the beat targets (baseChapter + offset).
|
||||
chapter: z.number().int().min(1),
|
||||
summary: z.string().min(1),
|
||||
});
|
||||
export type ForecastBeat = z.infer<typeof ForecastBeatSchema>;
|
||||
|
||||
export const ForecastCharacterDecisionSchema = z.object({
|
||||
character: z.string().min(1),
|
||||
decision: z.string().min(1),
|
||||
});
|
||||
export type ForecastCharacterDecision = z.infer<typeof ForecastCharacterDecisionSchema>;
|
||||
|
||||
export const ForecastProjectedChangesSchema = z.object({
|
||||
characters: z.array(z.string()),
|
||||
relationships: z.array(z.string()),
|
||||
world: z.array(z.string()),
|
||||
hooks: z.array(z.string()),
|
||||
});
|
||||
export type ForecastProjectedChanges = z.infer<typeof ForecastProjectedChangesSchema>;
|
||||
|
||||
export const ForecastIntentAlignmentSchema = z.object({
|
||||
// 0-100: how well the branch matches author_intent / current_focus.
|
||||
score: z.number().min(0).max(100),
|
||||
rationale: z.string().min(1),
|
||||
});
|
||||
export type ForecastIntentAlignment = z.infer<typeof ForecastIntentAlignmentSchema>;
|
||||
|
||||
export const ForecastBranchSchema = z.object({
|
||||
branchId: z.string().regex(/^branch-\d+$/),
|
||||
title: z.string().min(1),
|
||||
premise: z.string().min(1),
|
||||
beats: z.array(ForecastBeatSchema).min(1),
|
||||
characterDecisions: z.array(ForecastCharacterDecisionSchema),
|
||||
projectedChanges: ForecastProjectedChangesSchema,
|
||||
risks: z.array(ForecastRiskSchema),
|
||||
uncertainties: z.array(z.string()),
|
||||
intentAlignment: ForecastIntentAlignmentSchema,
|
||||
});
|
||||
export type ForecastBranch = z.infer<typeof ForecastBranchSchema>;
|
||||
|
||||
export const ForecastStatusSchema = z.enum(["active", "stale"]);
|
||||
export type ForecastStatus = z.infer<typeof ForecastStatusSchema>;
|
||||
|
||||
export const NarrativeForecastSchema = z.object({
|
||||
version: z.literal(1),
|
||||
forecastId: z.string().min(1),
|
||||
bookId: z.string().min(1),
|
||||
createdAt: z.string().min(1),
|
||||
language: z.enum(["zh", "en"]),
|
||||
divergence: z.string().min(1),
|
||||
horizon: z.number().int().min(FORECAST_MIN_HORIZON).max(FORECAST_MAX_HORIZON),
|
||||
baseChapter: z.number().int().min(0),
|
||||
contextFingerprint: z.string().min(1),
|
||||
status: ForecastStatusSchema,
|
||||
branches: z.array(ForecastBranchSchema).min(FORECAST_MIN_BRANCHES).max(FORECAST_MAX_BRANCHES),
|
||||
}).superRefine((forecast, ctx) => {
|
||||
const seen = new Set<string>();
|
||||
for (const branch of forecast.branches) {
|
||||
if (seen.has(branch.branchId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `duplicate branchId: ${branch.branchId}`,
|
||||
path: ["branches"],
|
||||
});
|
||||
}
|
||||
seen.add(branch.branchId);
|
||||
}
|
||||
});
|
||||
export type NarrativeForecast = z.infer<typeof NarrativeForecastSchema>;
|
||||
|
||||
// What the model must return. branchIds are assigned deterministically by the
|
||||
// runner (branch-1..branch-N) so the model cannot collide or skip ids.
|
||||
export const ForecastModelBranchSchema = ForecastBranchSchema.omit({ branchId: true });
|
||||
export type ForecastModelBranch = z.infer<typeof ForecastModelBranchSchema>;
|
||||
|
||||
export const ForecastModelOutputSchema = z.object({
|
||||
branches: z.array(ForecastModelBranchSchema).min(FORECAST_MIN_BRANCHES).max(FORECAST_MAX_BRANCHES),
|
||||
});
|
||||
export type ForecastModelOutput = z.infer<typeof ForecastModelOutputSchema>;
|
||||
|
||||
/**
|
||||
* Parse and validate the raw model response for a forecast run. Tolerates a
|
||||
* code fence, surrounding prose and trailing commas; anything else is a hard
|
||||
* error so an invalid response never reaches disk.
|
||||
*/
|
||||
export function parseForecastModelOutput(raw: string): ForecastModelOutput {
|
||||
const jsonSlice = extractJsonObject(stripCodeFence(raw.trim()));
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(sanitizeJson(jsonSlice));
|
||||
} catch (error) {
|
||||
throw new Error(`narrative forecast model output is not valid JSON: ${String(error)}`);
|
||||
}
|
||||
try {
|
||||
return ForecastModelOutputSchema.parse(parsed);
|
||||
} catch (error) {
|
||||
throw new Error(`narrative forecast model output failed schema validation: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stripCodeFence(value: string): string {
|
||||
const fenced = value.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
return fenced?.[1]?.trim() ?? value;
|
||||
}
|
||||
|
||||
function extractJsonObject(value: string): string {
|
||||
const start = value.indexOf("{");
|
||||
const end = value.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return value;
|
||||
return value.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function sanitizeJson(value: string): string {
|
||||
return value
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
||||
.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { NarrativeForecastSchema, type NarrativeForecast } from "./schema.js";
|
||||
|
||||
// All forecast artifacts live under this directory inside a book. Nothing in
|
||||
// this store may ever write outside of it — that is the v1 safety boundary
|
||||
// (story/state/*.json, story/*.md control docs and chapters/ stay untouched).
|
||||
|
||||
export interface ForecastStoreOptions {
|
||||
// Injectable clock so forecast ids and timestamps are deterministic in tests.
|
||||
readonly now?: () => Date;
|
||||
readonly idFactory?: () => string;
|
||||
}
|
||||
|
||||
export function assertSafeForecastId(value: string): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(value)) {
|
||||
throw new Error(`Invalid forecast id: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class ForecastStore {
|
||||
constructor(
|
||||
private readonly bookDir: string,
|
||||
private readonly options: ForecastStoreOptions = {},
|
||||
) {}
|
||||
|
||||
get forecastsDir(): string {
|
||||
return join(this.bookDir, "story", "runtime", "narrative-forecasts");
|
||||
}
|
||||
|
||||
forecastDir(forecastId: string): string {
|
||||
return join(this.forecastsDir, assertSafeForecastId(forecastId));
|
||||
}
|
||||
|
||||
forecastJsonPath(forecastId: string): string {
|
||||
return join(this.forecastDir(forecastId), "forecast.json");
|
||||
}
|
||||
|
||||
comparisonPath(forecastId: string): string {
|
||||
return join(this.forecastDir(forecastId), "comparison.md");
|
||||
}
|
||||
|
||||
selectedPlanPath(forecastId: string): string {
|
||||
return join(this.forecastDir(forecastId), "selected-branch-plan.md");
|
||||
}
|
||||
|
||||
now(): Date {
|
||||
return (this.options.now ?? (() => new Date()))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the next forecast id: injected factory first, otherwise a
|
||||
* timestamp from the injected clock; suffix -2/-3/... if the directory
|
||||
* already exists so re-runs never overwrite an earlier forecast.
|
||||
*/
|
||||
async allocateForecastId(): Promise<string> {
|
||||
const base = this.options.idFactory
|
||||
? assertSafeForecastId(this.options.idFactory())
|
||||
: `fc-${formatTimestamp(this.now())}`;
|
||||
let candidate = base;
|
||||
for (let suffix = 2; await pathExists(this.forecastDir(candidate)); suffix += 1) {
|
||||
candidate = `${base}-${suffix}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async save(
|
||||
forecast: NarrativeForecast,
|
||||
comparisonMarkdown: string,
|
||||
): Promise<{ readonly forecastJsonPath: string; readonly comparisonPath: string }> {
|
||||
// Validate before touching the filesystem so an invalid forecast never
|
||||
// leaves a half-written directory behind.
|
||||
const validated = NarrativeForecastSchema.parse(forecast);
|
||||
const dir = this.forecastDir(validated.forecastId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const forecastJsonPath = this.forecastJsonPath(validated.forecastId);
|
||||
const comparisonPath = this.comparisonPath(validated.forecastId);
|
||||
await writeFile(forecastJsonPath, `${JSON.stringify(validated, null, 2)}\n`, "utf-8");
|
||||
await writeFile(comparisonPath, `${comparisonMarkdown.trimEnd()}\n`, "utf-8");
|
||||
return { forecastJsonPath, comparisonPath };
|
||||
}
|
||||
|
||||
async load(forecastId: string): Promise<NarrativeForecast> {
|
||||
const path = this.forecastJsonPath(forecastId);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
const available = await this.list();
|
||||
throw new Error(
|
||||
`Narrative forecast "${forecastId}" not found. Available forecasts: ${available.length > 0 ? available.join(", ") : "(none)"}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Narrative forecast "${forecastId}" has corrupted forecast.json: ${String(error)}`);
|
||||
}
|
||||
try {
|
||||
return NarrativeForecastSchema.parse(parsed);
|
||||
} catch (error) {
|
||||
throw new Error(`Narrative forecast "${forecastId}" failed schema validation: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<ReadonlyArray<string>> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(this.forecastsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const ids: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (await pathExists(join(this.forecastsDir, entry, "forecast.json"))) {
|
||||
ids.push(entry);
|
||||
}
|
||||
}
|
||||
return ids.sort();
|
||||
}
|
||||
|
||||
async markStale(forecast: NarrativeForecast): Promise<NarrativeForecast> {
|
||||
const stale: NarrativeForecast = { ...forecast, status: "stale" };
|
||||
await writeFile(
|
||||
this.forecastJsonPath(stale.forecastId),
|
||||
`${JSON.stringify(NarrativeForecastSchema.parse(stale), null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return stale;
|
||||
}
|
||||
|
||||
async writeSelectedPlan(forecastId: string, markdown: string): Promise<string> {
|
||||
const path = this.selectedPlanPath(forecastId);
|
||||
await writeFile(path, `${markdown.trimEnd()}\n`, "utf-8");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const iso = date.toISOString();
|
||||
return `${iso.slice(0, 10).replace(/-/g, "")}-${iso.slice(11, 19).replace(/:/g, "")}`;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user