/** * Single-ticket trajectory tracer (factory board S1). * * Mechanism, not a role: deterministic scan → HTML. Unique seam: * (ledgerDir, ticketSnapshot, now) → HTML * * Station resolution (four-layer fallback): * 1) terminating tool name inside the session (ak__output) * 2) invocation.json role * 3) run-directory name heuristic * 4) unknown (still listed; never dropped) * * Receipt trust: only successful toolResults that pass typed contract * validation count as round results. Prior rejected attempts stay attempts. * * Page lifecycle: startTicketTrajectoryPage owns refresh regeneration to an * explicit output path outside the ledger and declares the bound; one-shot * write does not advertise refresh. Regeneration faults surface the original * cause via handle.closed / stop(). Caller stops the handle. */ import { randomUUID } from "node:crypto"; import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; import { formatDurationZh, formatLocalDateTime, formatTokensCompact, formatUsdPrecise, } from "./human-format.ts"; import { listBookRunDirectories } from "./role-run-placement.ts"; import { readRunTicketNumber } from "./run-ticket-number.ts"; import { extractSessionTimestampSpan, readLedgerSessionJsonl, type LedgerSessionRow, } from "./ledger-session-read.ts"; export { readLedgerSessionJsonl } from "./ledger-session-read.ts"; import { AcceptedDetailsContractError, acceptedFacts, isTerminatingToolName, validateAcceptedDetails, type TerminatingToolName, } from "./package-contracts/terminating-tools.ts"; import { PACKAGED_ROLE_REGISTRY } from "./packaged-role-registry.ts"; /** Declared refresh bound for the same viewing surface (seconds). */ export const DEFAULT_REFRESH_BOUNDARY_SECONDS = 30; /** Minimal ticket snapshot stub for S1 (no GitHub adapter). */ export type TicketSnapshot = { issueNumber: number; }; export type StationSource = "tool" | "invocation" | "name" | "unknown"; /** Injected clock + scheduler so tests drive the production lifecycle without wall sleep. */ export type TrajectoryClock = () => Date; export type TrajectoryScheduler = { /** Schedule `tick` every `ms` milliseconds; return a cancel function. */ every: (ms: number, tick: () => void) => () => void; }; export type TicketTrajectoryPageHandle = { readonly outputPath: string; /** Settles when the first page write finishes (or rejects on first failure). */ readonly started: Promise<{ outputPath: string; html: string }>; /** * Settles when the lifecycle ends. * Resolves on a clean stop with no regeneration fault; rejects with the * original cause when a post-start regeneration fails (or the initial write fails). */ readonly closed: Promise; /** * Stop further regeneration. In-flight write is awaited. * Re-throws the original regeneration failure when the lifecycle faulted. */ stop: () => Promise; }; type SessionRow = LedgerSessionRow; /** One ledger run as loaded by the S1 tracer (shared with the S2/S3 board). */ export type TicketTrajectoryRun = { runId: string; ledgerCoord: string; evidenceHref: string; startedAt?: string; /** Last session-record timestamp (parent session only; axis legs excluded). */ endedAt?: string; /** * Newest session-record timestamp across parent session + axis-leg sessions. * Differs from endedAt when an axis leg recorded activity after the parent. */ lastActivityAt?: string; /** Latest mtime among parent session + axis-leg session files (ms since epoch). */ mtimeMs: number; /** Sum of message.usage.cost.total across parent session + axis legs. */ costUsd: number; /** Sum of message.usage.totalTokens across parent session + axis legs. */ totalTokens: number; /** Sum of first→last wall ms across axis-leg sessions (parent excluded). */ axisWallMs: number; /** * Display wall ms for this run when a consumer precomputes it (board applies * the "latest unaccepted ends at now" rule). Absent → first→last of parent. */ wallMs?: number; station: string; stationSource: StationSource; attemptCount: number; hasResult: boolean; /** Receipt-level status when the terminating contract carries one. */ resultStatus: string; model: string; provider: string; thinking: string; }; type ParsedRun = TicketTrajectoryRun; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isMissingPathError(error: unknown): boolean { return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"); } /** realpath when the node exists; lexical path only for recognized absence — never for other errors. */ async function realpathOrLexicalIfMissing(path: string): Promise { try { return await realpath(path); } catch (error) { if (isMissingPathError(error)) return path; throw error; } } const TOOL_TO_ROLE: ReadonlyMap = new Map( PACKAGED_ROLE_REGISTRY.map((entry) => [entry.outputTool, entry.role]), ); const NAME_PREFIX_ROLES: readonly string[] = PACKAGED_ROLE_REGISTRY.map((entry) => entry.role); function roleFromToolName(toolName: string): string | undefined { return TOOL_TO_ROLE.get(toolName); } function roleFromRunName(runId: string): string | undefined { const base = runId.split("@")[0] ?? runId; const lower = base.toLowerCase(); // plan-court / *-court* → judge (ticket-court family) if (/(^|[-_])court([-_]|$)/.test(lower) || lower.startsWith("plan-court")) return "judge"; for (const role of NAME_PREFIX_ROLES) { if (lower === role || lower.startsWith(`${role}-`) || lower.startsWith(`${role}_`)) return role; } // review-* shorthand used heavily in the home ledger if (lower.startsWith("review")) return "reviewer"; return undefined; } function escapeHtml(text: string): string { return text .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function attr(value: string): string { return escapeHtml(value); } function extractModelFields(rows: SessionRow[]): { model: string; provider: string; thinking: string } { let model = ""; let provider = ""; let thinking = ""; for (const row of rows) { if (row.type === "model_change") { if (typeof row.provider === "string" && row.provider) provider = row.provider; if (typeof row.modelId === "string" && row.modelId) model = row.modelId; } if (row.type === "thinking_level_change" && typeof row.thinkingLevel === "string" && row.thinkingLevel) { thinking = row.thinkingLevel; } const message = isRecord(row.message) ? row.message : undefined; if (message?.role === "assistant") { if (typeof message.model === "string" && message.model) model = message.model; if (typeof message.provider === "string" && message.provider) provider = message.provider; } } return { model, provider, thinking }; } /** Sum budget dollars and tokens from message.usage on session rows. */ function extractUsageTotals(rows: SessionRow[]): { costUsd: number; totalTokens: number } { let costUsd = 0; let totalTokens = 0; for (const row of rows) { const message = isRecord(row.message) ? row.message : undefined; const usage = message && isRecord(message.usage) ? message.usage : isRecord(row.usage) ? row.usage : undefined; if (!usage) continue; if (typeof usage.totalTokens === "number" && Number.isFinite(usage.totalTokens)) { totalTokens += usage.totalTokens; } const cost = isRecord(usage.cost) ? usage.cost : undefined; if (cost && typeof cost.total === "number" && Number.isFinite(cost.total)) { costUsd += cost.total; } } return { costUsd, totalTokens }; } function wallMsBetween(startedAt: string | undefined, endedAt: string | undefined): number { if (!startedAt || !endedAt) return 0; const start = Date.parse(startedAt); const end = Date.parse(endedAt); if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0; return end - start; } function extractTerminatingLifecycle(rows: SessionRow[]): { attemptCount: number; toolNames: string[]; hasResult: boolean; resultStatus: string; } { let callAttempts = 0; let resultAttempts = 0; const toolNames: string[] = []; let hasResult = false; let resultStatus = ""; for (const row of rows) { const message = isRecord(row.message) ? row.message : undefined; if (!message) continue; if (message.role === "assistant" && Array.isArray(message.content)) { for (const part of message.content) { if (!isRecord(part) || part.type !== "toolCall") continue; const name = part.name; if (typeof name === "string" && isTerminatingToolName(name)) { callAttempts += 1; toolNames.push(name); } } } if (message.role === "toolResult" && typeof message.toolName === "string" && isTerminatingToolName(message.toolName)) { resultAttempts += 1; toolNames.push(message.toolName); if (message.isError === true) continue; if (!isRecord(message.details)) continue; try { const details = validateAcceptedDetails(message.toolName as TerminatingToolName, message.details); const facts = acceptedFacts(message.toolName as TerminatingToolName, details); hasResult = true; resultStatus = facts.status ?? ""; } catch (error) { if (error instanceof AcceptedDetailsContractError) continue; throw error; } } } // Prefer toolCall count; fall back to toolResult count when calls were clipped away. const attemptCount = callAttempts > 0 ? callAttempts : resultAttempts; return { attemptCount, toolNames, hasResult, resultStatus }; } type InvocationInfo = { role?: string; model?: string; provider?: string; thinking?: string; ticketNumber?: number; correlationId?: string; }; async function readInvocation(runDir: string): Promise { try { const raw = await readFile(join(runDir, "invocation.json"), "utf8"); const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) return undefined; const info: InvocationInfo = {}; if (typeof parsed.role === "string" && parsed.role.trim()) info.role = parsed.role.trim(); if (typeof parsed.thinking === "string" && parsed.thinking.trim()) info.thinking = parsed.thinking.trim(); if (typeof parsed.provider === "string" && parsed.provider.trim()) { info.provider = parsed.provider.trim(); } if (typeof parsed.model === "string" && parsed.model.trim()) { const rawModel = parsed.model.trim(); if (rawModel.includes("/")) { const slash = rawModel.indexOf("/"); // Combined provider/model form (legacy pages). if (info.provider === undefined) info.provider = rawModel.slice(0, slash); info.model = rawModel.slice(slash + 1); } else { info.model = rawModel; } } if (typeof parsed.correlationId === "string" && parsed.correlationId.trim() !== "") { info.correlationId = parsed.correlationId; } // Display placement: board pages first, then migration-derived (never forges board). const ticketNumber = await readRunTicketNumber(runDir); if (ticketNumber !== undefined) info.ticketNumber = ticketNumber; return info; } catch (error) { // Only genuine absence activates the invocation fallback. Malformed JSON and // unexpected IO failures retain their cause — never relabeled as "no invocation." if (isMissingPathError(error)) return undefined; throw error; } } function resolveStation(input: { toolNames: string[]; invocationRole?: string; runId: string; }): { station: string; stationSource: StationSource } { for (const toolName of input.toolNames) { const role = roleFromToolName(toolName); if (role) return { station: role, stationSource: "tool" }; } // Also accept tool names observed only via accepted results order if (input.invocationRole) { return { station: input.invocationRole, stationSource: "invocation" }; } const byName = roleFromRunName(input.runId); if (byName) return { station: byName, stationSource: "name" }; return { station: "unknown", stationSource: "unknown" }; } async function listSessionFiles(sessionDir: string): Promise { try { const entries = await readdir(sessionDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) .map((entry) => join(sessionDir, entry.name)) .sort(); } catch (error) { if (isMissingPathError(error)) return []; throw error; } } /** Reviewer parallel axis-leg sessions live under session/reviewer-legs/. */ async function listAxisLegSessionFiles(sessionDir: string): Promise { return listSessionFiles(join(sessionDir, "reviewer-legs")); } async function maxMtimeMs(paths: readonly string[]): Promise { let max = 0; for (const path of paths) { try { const st = await lstat(path); const ms = st.mtimeMs; if (Number.isFinite(ms) && ms > max) max = ms; } catch (error) { if (isMissingPathError(error)) continue; throw error; } } return max; } async function parseRunDirectory(runDir: string, ledgerCoord: string): Promise { const runId = basename(runDir); const evidenceTarget = await realpathOrLexicalIfMissing(runDir); const evidenceHref = pathToFileURL(evidenceTarget).href; const sessionDir = join(runDir, "session"); const sessionFiles = await listSessionFiles(sessionDir); const axisLegFiles = await listAxisLegSessionFiles(sessionDir); const rows: SessionRow[] = []; for (const file of sessionFiles) { rows.push(...(await readLedgerSessionJsonl(file))); } // Prefer explicit session header timestamp as start; else first record. let startedAt: string | undefined; for (const row of rows) { if (row.type === "session" && typeof row.timestamp === "string") { startedAt = row.timestamp; break; } if (!startedAt && typeof row.timestamp === "string") startedAt = row.timestamp; } const parentSpan = extractSessionTimestampSpan(rows); if (startedAt === undefined) startedAt = parentSpan.startedAt; const endedAt = parentSpan.endedAt; const parentUsage = extractUsageTotals(rows); let costUsd = parentUsage.costUsd; let totalTokens = parentUsage.totalTokens; let axisWallMs = 0; // Newest content activity across parent + axis (not parent-only endedAt). let lastActivityAt = parentSpan.endedAt; for (const file of axisLegFiles) { const legRows = await readLedgerSessionJsonl(file); const legUsage = extractUsageTotals(legRows); costUsd += legUsage.costUsd; totalTokens += legUsage.totalTokens; const legSpan = extractSessionTimestampSpan(legRows); axisWallMs += wallMsBetween(legSpan.startedAt, legSpan.endedAt); if ( legSpan.endedAt !== undefined && (lastActivityAt === undefined || legSpan.endedAt > lastActivityAt) ) { lastActivityAt = legSpan.endedAt; } } const mtimeMs = await maxMtimeMs([...sessionFiles, ...axisLegFiles]); const lifecycle = extractTerminatingLifecycle(rows); const models = extractModelFields(rows); const invocation = await readInvocation(runDir); const { station, stationSource } = resolveStation({ toolNames: lifecycle.toolNames, runId, ...(invocation?.role !== undefined ? { invocationRole: invocation.role } : {}), }); // Session mechanical fields win; invocation.json fills gaps only. let { model, provider, thinking } = models; if (invocation) { if (!thinking && invocation.thinking) thinking = invocation.thinking; if (!provider && invocation.provider) provider = invocation.provider; if (!model && invocation.model) model = invocation.model; } return { runId, ledgerCoord, evidenceHref, ...(startedAt !== undefined ? { startedAt } : {}), ...(endedAt !== undefined ? { endedAt } : {}), ...(lastActivityAt !== undefined ? { lastActivityAt } : {}), mtimeMs, costUsd, totalTokens, axisWallMs, station, stationSource, attemptCount: lifecycle.attemptCount, hasResult: lifecycle.hasResult, resultStatus: lifecycle.resultStatus, model, provider, thinking, }; } async function parseRun(ledgerDir: string, issueNumber: number, runId: string): Promise { const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId); const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/"); return parseRunDirectory(runDir, ledgerCoord); } async function listRunIds(ledgerDir: string, issueNumber: number): Promise { const runsDir = join(ledgerDir, "issues", String(issueNumber), "runs"); try { const entries = await readdir(runsDir, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); } catch (error) { if (isMissingPathError(error)) return []; throw error; } } function sortRuns(runs: readonly ParsedRun[]): ParsedRun[] { return [...runs].sort((a, b) => { const at = a.startedAt ?? ""; const bt = b.startedAt ?? ""; if (at !== bt) return at.localeCompare(bt); return a.runId.localeCompare(b.runId); }); } /** * Render station/run blocks from already-loaded S1 runs. * Shared by the single-ticket page and the S2 factory board (no second receipt parser). */ function formatUsd(value: number): string { // Full precision mechanical string — presentation may round; machines parse the attr. return Number.isFinite(value) ? String(value) : "0"; } function parentWallMs(run: TicketTrajectoryRun): number { if (typeof run.wallMs === "number" && Number.isFinite(run.wallMs) && run.wallMs >= 0) { return run.wallMs; } return wallMsBetween(run.startedAt, run.endedAt); } export function renderTicketTrajectoryStationHtml(runs: readonly TicketTrajectoryRun[]): string { const stationOrder: string[] = []; const byStation = new Map(); const sortedRuns = sortRuns(runs); for (const run of sortedRuns) { if (!byStation.has(run.station)) { byStation.set(run.station, []); stationOrder.push(run.station); } byStation.get(run.station)!.push(run); } return stationOrder .map((station) => { const rounds = byStation.get(station)!; const stationLabel = station === "unknown" ? "未知站" : station; let stationCost = 0; let stationTokens = 0; let stationWall = 0; for (const run of rounds) { stationCost += run.costUsd; stationTokens += run.totalTokens; // Station wall = each run's (possibly now-extended) wall + axis legs folded in. stationWall += parentWallMs(run) + run.axisWallMs; } const roundHtml = rounds .map((run) => { const resultDisplay = run.hasResult ? run.resultStatus || run.resultStatus : ""; // Machine channel: space-separated closed-enum tokens (no custom status dialect). const wall = parentWallMs(run); return [ `
`, `
`, `${escapeHtml(run.runId)}`, run.model || run.provider || run.thinking ? `${escapeHtml([run.provider, run.model].filter(Boolean).join("/"))}${run.thinking ? ` · ${escapeHtml(run.thinking)}` : ""}` : "", `
`, `

`, `attempts: ${run.attemptCount}`, run.hasResult ? `result: ${escapeHtml(resultDisplay)}` : `result: (none — attempts only)`, `$${escapeHtml(formatUsdPrecise(run.costUsd))} · ${escapeHtml(formatTokensCompact(run.totalTokens))} tok`, `墙钟 ${escapeHtml(formatDurationZh(wall))}`, `

`, `

${escapeHtml(run.ledgerCoord)}

`, `
`, ].join(""); }) .join("\n"); return [ `
`, `

${escapeHtml(stationLabel)} · ${rounds.length} 轮 · $${escapeHtml(formatUsdPrecise(stationCost))} · ${escapeHtml(formatTokensCompact(stationTokens))} tok · 墙钟 ${escapeHtml(formatDurationZh(stationWall))}

`, roundHtml, `
`, ].join("\n"); }) .join("\n"); } function renderHtml(input: { issueNumber: number; generatedAt: string; /** When set, page declares a refresh bound backed by active regeneration. Omit for one-shot. */ refreshBoundarySeconds?: number; runs: ParsedRun[]; }): string { const refreshActive = input.refreshBoundarySeconds !== undefined && Number.isFinite(input.refreshBoundarySeconds) && input.refreshBoundarySeconds > 0; const refreshBoundarySeconds = refreshActive ? input.refreshBoundarySeconds! : undefined; const sortedRuns = sortRuns(input.runs); const stationBlocks = renderTicketTrajectoryStationHtml(sortedRuns); const lifecycleAttrs = refreshActive ? ` data-lifecycle="refresh" data-refresh-boundary-seconds="${attr(String(refreshBoundarySeconds))}"` : ` data-lifecycle="oneshot"`; const refreshMeta = refreshActive ? ` ` : ""; const refreshNote = refreshActive ? `\n · refresh ≤ ${escapeHtml(String(refreshBoundarySeconds))}s` : ""; return ` ${refreshMeta} Ticket #${escapeHtml(String(input.issueNumber))} trajectory

Ticket #${escapeHtml(String(input.issueNumber))} · 驿传轨迹

生成于 ${refreshNote}

${stationBlocks || "

no runs

"}
`; } /** * Load one ticket's runs via the S1 tracer path (read-only ledger scan). * Factory board reuses this — no parallel receipt parser. * * Book-run attribution (#176 / #859): read typed `ticketNumber` from each run's * invocation.json under listBookRunDirectories (flat legacy `runs/` + subject-tree * `/runs/`). Legacy `issues//runs` remains a read-only * compatibility entrance. Unbound runs never join a ticket. */ /** One book run's typed binding projection from invocation.json. */ export type FlatRunTicketBinding = { readonly runDir: string; readonly runFolder: string; /** Book-relative coord (e.g. `runs/id@role` or `582/runs/id@role`). */ readonly ledgerCoord: string; readonly ticketNumber?: number; readonly role?: string; readonly correlationId?: string; }; /** * Book-level run index built once per lane/render and reused per ticket. * Pure derived view of book runs' invocation.json files — not a parallel ledger. */ export type TicketTrajectoryBookIndex = { /** ticket number → runs bound via invocation.json ticketNumber. */ readonly runsByTicket: ReadonlyMap; /** Runs with no typed ticketNumber (unknown/unbound seam). */ readonly unboundRuns: readonly FlatRunTicketBinding[]; }; export async function buildTicketTrajectoryBookIndex( ledgerDir: string, ): Promise { const root = resolve(ledgerDir); const runsByTicket = new Map(); const unboundRuns: FlatRunTicketBinding[] = []; for (const runDir of await listBookRunDirectories(root)) { const invocation = await readInvocation(runDir); const rel = relative(root, runDir).split(sep).join("/"); const binding: FlatRunTicketBinding = { runDir, runFolder: basename(runDir), ledgerCoord: rel, ...(invocation?.ticketNumber !== undefined ? { ticketNumber: invocation.ticketNumber } : {}), ...(invocation?.role !== undefined ? { role: invocation.role } : {}), ...(invocation?.correlationId !== undefined ? { correlationId: invocation.correlationId } : {}), }; if (binding.ticketNumber !== undefined) { const list = runsByTicket.get(binding.ticketNumber) ?? []; list.push(binding); runsByTicket.set(binding.ticketNumber, list); } else { unboundRuns.push(binding); } } return { runsByTicket, unboundRuns }; } export type UnboundTrajectoryRun = { readonly run: TicketTrajectoryRun; readonly role: string; readonly observedAt: string; readonly correlationId?: string; }; /** * Load unbound flat runs for the board unknown seam. * Never joins any ticket. Built from the book index (once). */ export async function loadUnboundTrajectoryRuns( ledgerDir: string, bookIndex?: TicketTrajectoryBookIndex, ): Promise { const root = resolve(ledgerDir); const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root)); const out: UnboundTrajectoryRun[] = []; for (const binding of index.unboundRuns) { const run = await parseRunDirectory(binding.runDir, binding.ledgerCoord); out.push({ run, role: binding.role ?? run.station, observedAt: run.startedAt ?? run.lastActivityAt ?? "", ...(binding.correlationId === undefined ? {} : { correlationId: binding.correlationId }), }); } return out; } export async function loadTicketTrajectoryRuns( ledgerDir: string, issueNumber: number, bookIndex?: TicketTrajectoryBookIndex, ): Promise { if (!Number.isInteger(issueNumber) || issueNumber < 1) { throw new Error("issueNumber must be a positive integer"); } const root = resolve(ledgerDir); const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root)); const runs: ParsedRun[] = []; const seenRunDirs = new Set(); // Legacy hand-dispatch path: issues//runs (read-only compatibility). const runIds = await listRunIds(root, issueNumber); for (const runId of runIds) { const parsed = await parseRun(root, issueNumber, runId); const legacyDir = join(root, "issues", String(issueNumber), "runs", runId); seenRunDirs.add(resolve(legacyDir)); runs.push(parsed); } // Book runs (flat + subject-tree) bound by typed invocation.json ticketNumber. const flatBindings = index.runsByTicket.get(issueNumber) ?? []; for (const binding of flatBindings) { const resolvedRunDir = resolve(binding.runDir); if (seenRunDirs.has(resolvedRunDir)) continue; seenRunDirs.add(resolvedRunDir); runs.push(await parseRunDirectory(binding.runDir, binding.ledgerCoord)); } return runs; } /** * Unique production seam: pure scan of the ledger + snapshot + now → HTML. * Read-only against the ledger. Snapshot is the S1 minimal stub. */ export async function renderTicketTrajectoryHtml( ledgerDir: string, ticketSnapshot: TicketSnapshot, now: Date, options?: { refreshBoundarySeconds?: number }, ): Promise { if (!isRecord(ticketSnapshot) || typeof ticketSnapshot.issueNumber !== "number" || !Number.isInteger(ticketSnapshot.issueNumber) || ticketSnapshot.issueNumber < 1) { throw new Error("ticketSnapshot.issueNumber must be a positive integer"); } const issueNumber = ticketSnapshot.issueNumber; const runs = await loadTicketTrajectoryRuns(ledgerDir, issueNumber); const generatedAt = now.toISOString(); // One-shot by default: only an explicit positive bound declares self-refresh, // and only callers that actually regenerate (startTicketTrajectoryPage) pass it. return renderHtml({ issueNumber, generatedAt, runs, ...(options?.refreshBoundarySeconds !== undefined ? { refreshBoundarySeconds: options.refreshBoundarySeconds } : {}), }); } function isPathInside(parent: string, child: string): boolean { const rel = relative(parent, child); return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith("..")); } /** * Resolve the prospective on-disk target of outputPath and refuse any landing * inside the ledger — including when the path, a parent, or a trailing segment * is a symlink into the ledger tree. */ export async function assertTrajectoryOutputOutsideLedger( ledgerDir: string, outputPath: string, ): Promise<{ ledgerRoot: string; outputAbsolute: string; prospectiveReal: string }> { const ledgerResolved = resolve(ledgerDir); let ledgerRoot: string; try { ledgerRoot = await realpath(ledgerResolved); } catch (error) { if (!isMissingPathError(error)) throw error; ledgerRoot = ledgerResolved; } const outputAbsolute = resolve(outputPath); // Walk up until an existing filesystem node is found; realpath that prefix // and rejoin the missing tail so symlink parents are fully followed. const missingTail: string[] = []; let cursor = outputAbsolute; for (;;) { try { await lstat(cursor); break; } catch (error) { if (!isMissingPathError(error)) throw error; const parent = dirname(cursor); if (parent === cursor) break; missingTail.push(basename(cursor)); cursor = parent; } } let realPrefix: string; try { realPrefix = await realpath(cursor); } catch (error) { if (!isMissingPathError(error)) throw error; realPrefix = resolve(cursor); } const prospectiveReal = missingTail.length === 0 ? realPrefix : resolve(realPrefix, ...missingTail.reverse()); if (isPathInside(ledgerRoot, prospectiveReal) || isPathInside(ledgerRoot, realPrefix)) { throw new Error("ticket trajectory outputPath must be outside the ledger directory"); } // Lexical absolute path must also stay outside (defense in depth before mkdir). if (isPathInside(ledgerRoot, outputAbsolute)) { throw new Error("ticket trajectory outputPath must be outside the ledger directory"); } return { ledgerRoot, outputAbsolute, prospectiveReal }; } /** * Write HTML to an explicit path outside the ledger without following an * existing destination inode (hard link / prior file). Temp file + rename * replaces the directory entry so a hard-linked ledger twin keeps its bytes. */ async function writeHtmlAtomicallyOutsideLedger(input: { ledgerRoot: string; outputAbsolute: string; html: string; }): Promise { const parent = dirname(input.outputAbsolute); await mkdir(parent, { recursive: true }); // Re-resolve after mkdir: a race or symlink parent must still land outside. const parentReal = await realpath(parent); if (isPathInside(input.ledgerRoot, parentReal)) { throw new Error("ticket trajectory outputPath must be outside the ledger directory"); } const destinationReal = resolve(parentReal, basename(input.outputAbsolute)); if (isPathInside(input.ledgerRoot, destinationReal)) { throw new Error("ticket trajectory outputPath must be outside the ledger directory"); } // Refuse to write through an existing symlink whose target is inside the ledger. try { const existing = await lstat(input.outputAbsolute); if (existing.isSymbolicLink()) { const target = await realpath(input.outputAbsolute); if (isPathInside(input.ledgerRoot, target)) { throw new Error("ticket trajectory outputPath must be outside the ledger directory"); } } } catch (error) { if (!isMissingPathError(error)) throw error; } // Same-directory temp + rename: does not open/truncate an existing inode, so a // hard link from outputPath into the ledger cannot smuggle writes back home. const temporary = join(parent, `.ticket-trajectory-${randomUUID()}.html.tmp`); try { await writeFile(temporary, input.html, "utf8"); await rename(temporary, input.outputAbsolute); } catch (error) { await rm(temporary, { force: true }).catch(() => undefined); throw error; } return realpath(input.outputAbsolute); } /** * Page write seam: render via the unique seam and write ONLY to an explicit * path outside the ledger. Caller owns output location. */ export async function writeTicketTrajectoryPage(input: { ledgerDir: string; ticketSnapshot: TicketSnapshot; now: Date; outputPath: string; refreshBoundarySeconds?: number; }): Promise<{ outputPath: string; html: string }> { const gate = await assertTrajectoryOutputOutsideLedger(input.ledgerDir, input.outputPath); const html = await renderTicketTrajectoryHtml( input.ledgerDir, input.ticketSnapshot, input.now, input.refreshBoundarySeconds !== undefined ? { refreshBoundarySeconds: input.refreshBoundarySeconds } : undefined, ); const outputPath = await writeHtmlAtomicallyOutsideLedger({ ledgerRoot: gate.ledgerRoot, outputAbsolute: gate.outputAbsolute, html, }); return { outputPath, html }; } const defaultScheduler: TrajectoryScheduler = { every(ms, tick) { const timer = setInterval(tick, ms); // Allow the process to exit naturally if the caller forgets stop in scripts. timer.unref?.(); return () => clearInterval(timer); }, }; /** * Production page lifecycle: write immediately, then regenerate on the declared * refresh boundary so the same viewing surface observes new runs / generated-at. * Stop cancels further regeneration. A post-start regeneration failure faults the * lifecycle with the original cause (no silent continuation). Ledger remains read-only. */ export function startTicketTrajectoryPage(input: { ledgerDir: string; ticketSnapshot: TicketSnapshot; outputPath: string; refreshBoundarySeconds?: number; clock?: TrajectoryClock; scheduler?: TrajectoryScheduler; }): TicketTrajectoryPageHandle { const refreshBoundarySeconds = input.refreshBoundarySeconds ?? DEFAULT_REFRESH_BOUNDARY_SECONDS; if (!(refreshBoundarySeconds > 0) || !Number.isFinite(refreshBoundarySeconds)) { throw new Error("refreshBoundarySeconds must be a positive finite number"); } const clock = input.clock ?? (() => new Date()); const scheduler = input.scheduler ?? defaultScheduler; let stopped = false; let cancel: (() => void) | undefined; let inFlight: Promise | undefined; let lastRejection: unknown; let closedSettled = false; let resolveClosed!: () => void; let rejectClosed!: (error: unknown) => void; const closed = new Promise((resolve, reject) => { resolveClosed = resolve; rejectClosed = reject; }); // Prevent unhandled-rejection crashes when callers only await stop()/started. void closed.catch(() => undefined); const fault = (error: unknown): void => { if (lastRejection !== undefined) return; lastRejection = error; stopped = true; cancel?.(); cancel = undefined; if (!closedSettled) { closedSettled = true; rejectClosed(error); } }; const settleClean = (): void => { if (closedSettled) return; closedSettled = true; resolveClosed(); }; const writeOnce = async (): Promise<{ outputPath: string; html: string }> => { if (stopped && lastRejection === undefined) { throw new Error("ticket trajectory page lifecycle already stopped"); } if (lastRejection !== undefined) throw lastRejection; return writeTicketTrajectoryPage({ ledgerDir: input.ledgerDir, ticketSnapshot: input.ticketSnapshot, now: clock(), outputPath: input.outputPath, refreshBoundarySeconds, }); }; const queueWrite = (): void => { if (stopped || lastRejection !== undefined) return; inFlight = (inFlight ?? Promise.resolve()).then(async () => { if (stopped || lastRejection !== undefined) return; try { await writeOnce(); } catch (error) { fault(error); } }); }; const started = writeOnce() .then((first) => { if (stopped || lastRejection !== undefined) return first; const intervalMs = Math.max(1, Math.round(refreshBoundarySeconds * 1000)); cancel = scheduler.every(intervalMs, () => { queueWrite(); }); return first; }) .catch((error) => { fault(error); throw error; }); // Capture output path from the absolute resolution even before first write settles. const outputPath = resolve(input.outputPath); return { outputPath, started, closed, async stop() { stopped = true; cancel?.(); cancel = undefined; await started.catch(() => undefined); if (inFlight) await inFlight.catch(() => undefined); if (lastRejection !== undefined) { // closed already rejected with the original cause throw lastRejection; } settleClean(); }, }; }