import { join } from "node:path"; import { CONFIG_DIR_NAME } from "../config/types"; import { parseModelKey } from "../episode/types"; import type { ReviewIndexStoreOptions, FallbackReviewIndex, ReviewMirror, ReviewMirrorEpisode, ReviewRebuildResult, ReviewScheduleMetadata, RebuildPhase } from "./types"; import { REVIEW_ENTRY_TYPE } from "./types"; export { REVIEW_ENTRY_TYPE } from "./types"; export const reviewMirrorPath = (cwd: string): string => join(cwd, CONFIG_DIR_NAME, "fallback-review.json"); export const mirrorPath = reviewMirrorPath; const substates = ["discriminating", "hourly-probing", "awaiting-resets_at", "outage-pacing"] as const; const statuses = ["active", "reverted", "suspended-by-user", "given-up"] as const; type RecordValue = Record; const record = (value: unknown): value is RecordValue => !!value && typeof value === "object" && !Array.isArray(value); const text = (value: unknown): value is string => typeof value === "string" && value.length > 0; const model = (value: unknown): value is string => text(value) && !!parseModelKey(value); const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); const oneOf = (value: unknown, values: readonly T[]): value is T => typeof value === "string" && values.includes(value as T); export function decodeReviewIndex(value: unknown): FallbackReviewIndex | undefined { if (!record(value) || value.version !== 1 || !text(value.episodeId) || !model(value.primary) || !oneOf(value.status, statuses) || typeof value.reviewed !== "boolean" || !Array.isArray(value.flaggedFiles) || !Array.isArray(value.shellActivity)) return undefined; // R16: `tier` is validated as `1|2` when present — a present-but-impossible value is a hand-edited or // damaged record, and this decoder is strict about every other field for the same reason — and it is // EXPLICITLY re-emitted below. The rebuild is key-by-key, so a field that is not named there is // dropped in silence: the record would keep its tier in memory and lose it on the next reload, which // is exactly the disk round-trip R16 is stated over. Shell records gain nothing (NG-9/AC-20). if (!value.flaggedFiles.every((item) => record(item) && text(item.path) && model(item.model) && text(item.timestamp) && (item.tier === undefined || item.tier === 1 || item.tier === 2)) || !value.shellActivity.every((item) => record(item) && text(item.command) && text(item.toolCallId) && text(item.timestamp))) return undefined; return { version: 1, episodeId: value.episodeId, primary: value.primary, status: value.status, reviewed: value.reviewed, flaggedFiles: value.flaggedFiles.map((item) => ({ path: item.path as string, model: item.model as string, timestamp: item.timestamp as string, ...(item.tier === undefined ? {} : { tier: item.tier as 1 | 2 }) })), shellActivity: value.shellActivity.map((item) => ({ command: item.command as string, toolCallId: item.toolCallId as string, timestamp: item.timestamp as string })) }; } export const decodeIndex = decodeReviewIndex; export function decodeSchedule(value: unknown): ReviewScheduleMetadata | undefined { if (!record(value) || !oneOf(value.substate, substates) || !finite(value.probeCount) || !finite(value.consecutiveOutage) || !(value.resetsAt === null || finite(value.resetsAt)) || !finite(value.lastProbeAt) || !(value.nextDueAt === undefined || finite(value.nextDueAt)) || !finite(value.segmentDepth) || !Number.isInteger(value.segmentDepth) || value.segmentDepth < 0 || !Array.isArray(value.segments) || !value.segments.every(model)) return undefined; const segmentDepth = value.segmentDepth as number; return { substate: value.substate, probeCount: value.probeCount, consecutiveOutage: value.consecutiveOutage, resetsAt: value.resetsAt, lastProbeAt: value.lastProbeAt, ...(value.nextDueAt === undefined ? {} : { nextDueAt: value.nextDueAt as number }), segmentDepth, segments: [...value.segments] }; } export const REVIEW_MIRROR_VERSION = 1; export interface DecodedReviewMirror { mirror: ReviewMirror | undefined; malformedEntries: number; corrupt: boolean; versionAhead: boolean; newestDiscarded: boolean; } export function decodeReviewMirror(value: unknown): DecodedReviewMirror { // R3-whole-mirror-corruption-still-silent: a whole-file shape failure is corruption, not absence — // `corrupt: true` lets a caller distinguish it from "no mirror file was ever written". // R3-version-mismatch-classified-as-corruption: a file written by a NEWER build is a third fact // again. It is unreadable here, but nothing is damaged and the operator's action is different, so it // is never reported as corruption. if (!record(value) || value.version !== REVIEW_MIRROR_VERSION || !text(value.updatedAt) || !record(value.episodes)) { const versionAhead = record(value) && typeof value.version === "number" && Number.isInteger(value.version) && value.version > REVIEW_MIRROR_VERSION; return { mirror: undefined, malformedEntries: 0, corrupt: !versionAhead, versionAhead, newestDiscarded: false }; } const episodes: Record = {}; const rawKeys = Object.keys(value.episodes); let malformedEntries = 0; for (const [episodeId, item] of Object.entries(value.episodes)) { if (!record(item)) { malformedEntries++; continue; } const index = decodeReviewIndex(item.index), schedule = decodeSchedule(item.schedule); if (!index || index.episodeId !== episodeId || !schedule) { malformedEntries++; continue; } episodes[episodeId] = { index, schedule }; } // R3-stale-seed-on-newest-malformed: `rawKeys` preserves insertion order, so its last entry is the // newest episode written. If that exact entry did not survive decoding, whatever `.at(-1)` on the // decoded map now returns (the seeding call site) is one episode staler than it appears. const newestRawKey = rawKeys.at(-1); const newestDiscarded = newestRawKey !== undefined && !(newestRawKey in episodes); return { mirror: { version: 1, updatedAt: value.updatedAt, episodes }, malformedEntries, corrupt: false, versionAhead: false, newestDiscarded }; } export const decodeMirror = decodeReviewMirror; const defaultSchedule = (): ReviewScheduleMetadata => ({ substate: "hourly-probing", probeCount: 0, consecutiveOutage: 0, resetsAt: null, lastProbeAt: 0, segmentDepth: 0, segments: [] }); const unwrap = (value: unknown): { payload: unknown; custom: boolean } | undefined => { if (!record(value)) return undefined; if (value.type === REVIEW_ENTRY_TYPE || value.customType === REVIEW_ENTRY_TYPE || value.name === REVIEW_ENTRY_TYPE) return { payload: value.data, custom: true }; if (value.version === 1 && ("episodeId" in value || "status" in value || "flaggedFiles" in value)) return { payload: value, custom: true }; return undefined; }; const scanBranch = (values: readonly unknown[]): { index?: FallbackReviewIndex; malformedEntries: number } => { let index: FallbackReviewIndex | undefined, malformedEntries = 0; for (const value of values) { const candidate = unwrap(value); if (!candidate) continue; const decoded = decodeReviewIndex(candidate.payload); if (decoded) index = decoded; else malformedEntries++; } return { index, malformedEntries }; }; export class ReviewIndexStore { private current?: FallbackReviewIndex; private currentSchedule?: ReviewScheduleMetadata; private writes = Promise.resolve(); constructor(private readonly options: ReviewIndexStoreOptions) {} snapshot(): FallbackReviewIndex | undefined { return this.current; } schedule(): ReviewScheduleMetadata | undefined { return this.currentSchedule; } async mirror(): Promise { return (await this.readMirror()).mirror; } async persist(value: FallbackReviewIndex, schedule?: ReviewScheduleMetadata): Promise { const run = this.writes.then(async () => { const index = decodeReviewIndex(value); if (!index) throw new TypeError("invalid fallback review index"); const metadata = decodeSchedule(schedule ?? (this.current?.episodeId === index.episodeId ? this.currentSchedule : undefined) ?? defaultSchedule()); if (!metadata) throw new TypeError("invalid fallback review schedule"); this.options.entries.append(REVIEW_ENTRY_TYPE, index); this.current = index; this.currentSchedule = metadata; const prior = (await this.readMirror()).mirror; const episodes = { ...(prior?.episodes ?? {}) }; episodes[index.episodeId] = { index, schedule: metadata }; const mirror: ReviewMirror = { version: 1, updatedAt: this.options.clock?.iso() ?? new Date().toISOString(), episodes }; await this.options.fs.writeAtomic(reviewMirrorPath(this.options.cwd), `${JSON.stringify(mirror, null, 2)}\n`); return index; }); this.writes = run.then(() => undefined, () => undefined); return run; } appendSnapshot(value: FallbackReviewIndex, schedule?: ReviewScheduleMetadata): Promise { return this.persist(value, schedule); } record(value: FallbackReviewIndex, schedule?: ReviewScheduleMetadata): Promise { return this.persist(value, schedule); } async updateSchedule(schedule: ReviewScheduleMetadata): Promise { const run = this.writes.then(async () => { const current = this.current; if (!current) return; const metadata = decodeSchedule(schedule); if (!metadata) throw new TypeError("invalid fallback review schedule"); this.currentSchedule = metadata; const prior = (await this.readMirror()).mirror; const episodes = { ...(prior?.episodes ?? {}) }; const episode = episodes[current.episodeId]; episodes[current.episodeId] = { index: episode?.index ?? current, schedule: metadata }; const mirror: ReviewMirror = { version: 1, updatedAt: this.options.clock?.iso() ?? new Date().toISOString(), episodes }; await this.options.fs.writeAtomic(reviewMirrorPath(this.options.cwd), `${JSON.stringify(mirror, null, 2)}\n`); }); this.writes = run.then(() => undefined, () => undefined); return run; } async rebuild(phase: RebuildPhase = "session-start"): Promise { this.current = undefined; this.currentSchedule = undefined; const scanned = scanBranch(this.options.entries.branch()); if (scanned.index) { this.current = scanned.index; const { mirror, malformedEntries, corrupt, versionAhead } = await this.readMirror(); this.currentSchedule = mirror?.episodes[scanned.index.episodeId]?.schedule; return { index: scanned.index, schedule: this.currentSchedule, malformedEntries: scanned.malformedEntries + malformedEntries, mirrorCorrupt: corrupt, mirrorVersionAhead: versionAhead, staleSeed: false, seeded: false }; } if (phase !== "session-start" && phase !== "session_start") return { malformedEntries: scanned.malformedEntries, mirrorCorrupt: false, mirrorVersionAhead: false, staleSeed: false, seeded: false }; const { mirror, malformedEntries, corrupt, versionAhead, newestDiscarded } = await this.readMirror(), episode = mirror && Object.values(mirror.episodes).at(-1); const total = scanned.malformedEntries + malformedEntries; if (!episode) return { malformedEntries: total, mirrorCorrupt: corrupt, mirrorVersionAhead: versionAhead, staleSeed: false, seeded: false }; this.options.entries.append(REVIEW_ENTRY_TYPE, episode.index); this.current = episode.index; this.currentSchedule = episode.schedule; return { index: episode.index, schedule: episode.schedule, malformedEntries: total, mirrorCorrupt: corrupt, mirrorVersionAhead: versionAhead, staleSeed: newestDiscarded === true, seeded: true }; } sessionStart(): Promise { return this.rebuild("session-start"); } sessionTree(): Promise { return this.rebuild("session-tree"); } private async readMirror(): Promise { try { const contents = await this.options.fs.read(reviewMirrorPath(this.options.cwd)); return contents === undefined ? { mirror: undefined, malformedEntries: 0, corrupt: false, versionAhead: false, newestDiscarded: false } : decodeReviewMirror(JSON.parse(contents)); } catch { // The mirror file existed but its text could not even be parsed as JSON — corrupt, not absent. return { mirror: undefined, malformedEntries: 0, corrupt: true, versionAhead: false, newestDiscarded: false }; } } }