/** * Analyst ledger scan: S-family book/runs topology, book × ticket issue scope * (#399), and loud unreadable exclusion for required sources. * Reuses canonical session/artifact readers — does not parse session JSONL itself. * * Scope unit = ledger book (git common-dir key) × optional typed ticket filter. * CLI issue query never path-filters by projectRoot (owner #399: that face deleted). * Sweep/legacy library may still pass projectRoot as a path-narrow when bookKey * is absent — recording-side field, not the public query mechanical key. * Typed ticketNumber, when requested, decides alone (no silent projectRoot fallback). * * A2: classifyScopedRun retains typed per-run facts (frame span, tool intervals, * terminal face) for metric-family modules — no longer discarded after checks. */ import { readdir, readFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { resolveBookKeyFromGit } from "./activation-ledger-git.ts"; import { pathContainedIn, physicalPathIdentity, resolveActivationLedgerHome, } from "./activation-ledger-topology.ts"; import { listBookRunDirectories } from "./role-run-placement.ts"; import { autopsyWriterLock } from "./public-cli/run-lifecycle.ts"; import { readRunTicketNumber } from "./run-ticket-number.ts"; import { extractSessionModelSequence, extractSessionTimestampSpan, extractSessionToolIntervals, LedgerSessionJsonlError, readLedgerSessionJsonl, type LedgerSessionRow, type SessionToolInterval, } from "./ledger-session-read.ts"; import { readRunTerminalArtifact, type RunTerminalArtifactFile, } from "./run-terminal-artifacts.ts"; import type { AnalystFirstFrameAt, AnalystMissingSource, AnalystOptionalTimestamp, AnalystScopeConflict, AnalystUnreadableRun, } from "./analyst-page.ts"; import { readAnalystGateCyclesFromAuditorRoles, type AnalystGateCycleRound, } from "./analyst-gate-cycles-read.ts"; export type { AnalystGateCycleRound } from "./analyst-gate-cycles-read.ts"; function isMissingPathError(error: unknown): boolean { return ( error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR") ); } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Existing public-cli run-state live phases — not a new state machine. */ const LIVE_RUN_STATES = new Set(["admitted", "running", "resumable"]); /** * Read lifecycle state from the existing run-state.json face. * Used only to distinguish live in-flight runs from terminal no-receipt. */ async function readExistingRunLifecycleState( runDirectory: string, ): Promise { try { const raw: unknown = JSON.parse( await readFile(join(runDirectory, "run-state.json"), "utf8"), ); if (!isRecord(raw) || typeof raw.state !== "string") return undefined; return raw.state; } catch { return undefined; } } /** * #855 lease check projection for ghost legs — index facts only (ADR 0049). * Does not rename autopsy verdicts into stronger claims than observed. */ export type AnalystGhostLeaseCheck = | { readonly kind: "holder-dead"; readonly pid: number } | { readonly kind: "no-lease" } | { readonly kind: "unverifiable"; readonly reason: "unreadable" | "unparseable" }; export type AnalystGhostLeg = { readonly runId: string; readonly runState: "admitted" | "running"; readonly leaseCheck: AnalystGhostLeaseCheck; }; function ghostLeaseCheckFromAutopsy( autopsy: Awaited>, ): AnalystGhostLeaseCheck | undefined { switch (autopsy.verdict) { case "alive": return undefined; case "dead": return { kind: "holder-dead", pid: autopsy.pid }; case "absent": return { kind: "no-lease" }; case "unknown": return { kind: "unverifiable", reason: autopsy.reason === "unreadable" ? "unreadable" : "unparseable", }; } } /** * Classify one admitted|running run against writer lease liveness (#855). * Alive holder → still in-flight (omit). Otherwise → ghost report fact only. */ async function classifyGhostCandidate(input: { readonly runId: string; readonly runState: "admitted" | "running"; readonly runDirectory: string; }): Promise< | { readonly kind: "live" } | { readonly kind: "ghost"; readonly leg: AnalystGhostLeg } > { const autopsy = await autopsyWriterLock(join(input.runDirectory, "writer.lock")); const leaseCheck = ghostLeaseCheckFromAutopsy(autopsy); if (leaseCheck === undefined) return { kind: "live" }; return { kind: "ghost", leg: { runId: input.runId, runState: input.runState, leaseCheck, }, }; } function parseRunDirectoryName( name: string, ): { runId: string; role: string } | undefined { const at = name.lastIndexOf("@"); if (at <= 0 || at === name.length - 1) return undefined; return { runId: name.slice(0, at), role: name.slice(at + 1) }; } /** * Invocation scope faces used for issue 圈定 (C4 / #399). * projectRoot is retained for narrow path match and conflict facts; * ticketNumber is the #176 typed face when present (integer ≥ 1). * Single read of invocation.json — no second parse kernel. */ type InvocationScopeFields = { readonly projectRoot: string; readonly ticketNumber?: number; }; /** Best-effort book key from a projectRoot git common-dir; undefined when not a git tree. */ function tryResolveBookKeyFromProjectRoot(projectRoot: string): string | undefined { try { return resolveBookKeyFromGit(projectRoot); } catch { return undefined; } } async function listLedgerBookNames(booksRoot: string): Promise { try { const entries = await readdir(booksRoot, { withFileTypes: true }); return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); } catch (error) { if (isMissingPathError(error)) return []; throw error; } } async function readInvocationScopeFields( runDirectory: string, ): Promise { let raw: string; try { raw = await readFile(join(runDirectory, "invocation.json"), "utf8"); } catch (error) { if (isMissingPathError(error)) return undefined; throw error; } const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) return undefined; if (typeof parsed.projectRoot !== "string" || parsed.projectRoot.trim() === "") { return undefined; } const projectRoot = parsed.projectRoot; // Display/history placement: board first, then migration-derived (#852). const ticketNumber = await readRunTicketNumber(runDirectory); if (ticketNumber !== undefined) { return { projectRoot, ticketNumber }; } return { projectRoot }; } /** * Issue scope decision for one run (#399 / C4). * - Scope ticket set → typed ticket alone decides (match in; else out). * No projectRoot fallback — that silent path labeled full-project pages as ticket N. * - Whole-book scope (CLI bare / bookKey without ticket) → every run in the book. * - Path-narrow (sweep/legacy only): no ticket + scopeRootIdentity → path match. */ function decideIssueScope(input: { readonly scopeTicketNumber: number | undefined; /** Whole-book membership when true (CLI bare / git-resolved book). */ readonly wholeBook: boolean; readonly scopeRootIdentity: string | undefined; readonly runProjectRootIdentity: string; readonly runTicketNumber: number | undefined; }): { readonly inScope: boolean } { if (input.scopeTicketNumber !== undefined) { // Strict (book, N): typed ticket alone; never fall back to path match. return { inScope: input.runTicketNumber === input.scopeTicketNumber }; } if (input.wholeBook) { return { inScope: true }; } if (input.scopeRootIdentity !== undefined) { return { inScope: input.runProjectRootIdentity === input.scopeRootIdentity, }; } return { inScope: true }; } async function resolveSessionFile( runDirectory: string, ): Promise { // Prefer invocation.sessionFile when present; fall back to S-family principal. try { const raw = await readFile(join(runDirectory, "invocation.json"), "utf8"); const parsed: unknown = JSON.parse(raw); if ( isRecord(parsed) && typeof parsed.sessionFile === "string" && parsed.sessionFile.trim() !== "" ) { return parsed.sessionFile; } } catch (error) { if (!isMissingPathError(error)) throw error; } return join(runDirectory, "session", "session.jsonl"); } /** * Attribute session rows to one run. * Private run volumes (session under runDirectory) keep the whole file. * External volumes stay honest missing — never silent full ownership. */ function attributeSessionRowsForRun(input: { readonly rows: readonly LedgerSessionRow[]; readonly sessionFile: string; readonly runDirectory: string; readonly runId: string; }): | { readonly kind: "attributed"; readonly rows: readonly LedgerSessionRow[]; readonly closed: boolean; } | { readonly kind: "missing-boundary"; readonly reason: string } { if (pathContainedIn(input.runDirectory, input.sessionFile)) { return { kind: "attributed", rows: input.rows, closed: false }; } return { kind: "missing-boundary", reason: "session volume is outside the run directory", }; } /** * Admit frame span (and partial edges) from already-attributed session rows. * Shared by the clean parse path and closed-interval damage recovery. */ function admitFrameSpanFromRows(rows: readonly LedgerSessionRow[]): { readonly frameSpan?: AnalystRunFrameSpan; readonly missingReason?: string; readonly partialFirstFrameAt: AnalystFirstFrameAt; readonly partialLastFrameAt: AnalystOptionalTimestamp; readonly models: readonly string[]; } { const models = extractSessionModelSequence(rows); const span = extractSessionTimestampSpan(rows); let partialFirstFrameAt: AnalystFirstFrameAt = { status: "absent" }; let partialLastFrameAt: AnalystOptionalTimestamp = { status: "absent" }; if (span.startedAt === undefined || span.endedAt === undefined) { if (span.startedAt !== undefined) { partialFirstFrameAt = { status: "present", at: span.startedAt }; } if (span.endedAt !== undefined) { partialLastFrameAt = { status: "present", at: span.endedAt }; } return { models, partialFirstFrameAt, partialLastFrameAt, missingReason: "session timeline has no usable timestamps", }; } const startedMs = Date.parse(span.startedAt); const endedMs = Date.parse(span.endedAt); if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs)) { return { models, partialFirstFrameAt: { status: "present", at: span.startedAt }, partialLastFrameAt: { status: "present", at: span.endedAt }, missingReason: "session timeline timestamps are not parseable instants", }; } if (endedMs < startedMs) { return { models, partialFirstFrameAt: { status: "present", at: span.startedAt }, partialLastFrameAt: { status: "present", at: span.endedAt }, missingReason: "session timeline end is earlier than start", }; } return { models, frameSpan: { startedAt: span.startedAt, endedAt: span.endedAt }, partialFirstFrameAt: { status: "present", at: span.startedAt }, partialLastFrameAt: { status: "present", at: span.endedAt }, }; } /** Session first/last usable timestamps retained for B-wave wall-clock kernels. */ export type AnalystRunFrameSpan = { readonly startedAt: string; readonly endedAt: string; }; /** * Typed terminal face retained for B-wave outcome mapping. * Absence is a valid no-receipt state; unreadable never appears here (excluded). */ export type AnalystRunTerminalFace = | { readonly status: "absent" } | { readonly status: "present"; readonly file: RunTerminalArtifactFile; readonly body: Record; /** Nonblank producer role — sole owner is readRunTerminalArtifact. */ readonly role: string; }; /** * Per readable in-scope run: identity + facts classifyScopedRun already read. * Metric families consume this structure read-only; they do not re-scan disk. */ export type AnalystReadableRunFacts = { readonly runId: string; readonly book: string; readonly role: string; readonly frameSpan: AnalystRunFrameSpan; readonly toolIntervals: readonly SessionToolInterval[]; readonly terminal: AnalystRunTerminalFace; /** * Ordered-unique session model ids for this leg (C3 model-group key material). * Empty when no model face was present — not a scan-level unreadable condition * (timeline/tools/terminal still admit the run for issue-mode families). * Model-groups mode lists empty as typed session-model vacancy, never as "". */ readonly models: readonly string[]; /** * Paired gate-cycle rounds from session/auditor-roles/ (#446). * Missing directory → empty (lawful zero rounds). * Damaged discovered nested JSONL → leg unreadable (`auditor-roles` source). */ readonly gateCycles: readonly AnalystGateCycleRound[]; }; export type AnalystScopedRunScan = { /** Readable in-scope runs with retained typed facts (A2). */ readonly runs: readonly AnalystReadableRunFacts[]; readonly unreadable: readonly AnalystUnreadableRun[]; /** C4: typed-ticket admits whose projectRoot mechanical key conflicted. */ readonly scopeConflicts: readonly AnalystScopeConflict[]; /** #855: admitted|running runs whose writer lease cannot prove a live holder. */ readonly ghostLegs: readonly AnalystGhostLeg[]; }; async function classifyScopedRun(input: { readonly book: string; readonly runId: string; readonly role: string; readonly runDirectory: string; }): Promise< | { readonly kind: "readable"; readonly facts: AnalystReadableRunFacts; /** #855: present terminal still lists ghost when lease cannot prove live. */ readonly ghostLeg?: AnalystGhostLeg; } | { readonly kind: "unreadable"; readonly entry: AnalystUnreadableRun; /** #855: unreadable terminal still lists ghost when lease cannot prove live. */ readonly ghostLeg?: AnalystGhostLeg; } | { readonly kind: "live" } | { readonly kind: "ghost"; readonly leg: AnalystGhostLeg } > { const missingSources: AnalystMissingSource[] = []; const reasons: string[] = []; let frameSpan: AnalystRunFrameSpan | undefined; let toolIntervals: readonly SessionToolInterval[] | undefined; let terminal: AnalystRunTerminalFace | undefined; /** Ordered-unique models retained whenever session rows were readable. */ let models: readonly string[] = []; /** Partial first/last frame retained when full session span cannot be admitted. */ let partialFirstFrameAt: AnalystFirstFrameAt = { status: "absent" }; let partialLastFrameAt: AnalystOptionalTimestamp = { status: "absent" }; // 1) session timeline from the run-private volume. const sessionFile = await resolveSessionFile(input.runDirectory); let rows: readonly LedgerSessionRow[] | undefined; const admitAttributed = ( attributedRows: readonly LedgerSessionRow[], ): void => { rows = attributedRows; const admitted = admitFrameSpanFromRows(attributedRows); models = admitted.models; partialFirstFrameAt = admitted.partialFirstFrameAt; partialLastFrameAt = admitted.partialLastFrameAt; if (admitted.frameSpan !== undefined) { frameSpan = admitted.frameSpan; } else if (admitted.missingReason !== undefined) { missingSources.push("session-timeline"); reasons.push(admitted.missingReason); } }; try { const rawRows = await readLedgerSessionJsonl(sessionFile); const attributed = attributeSessionRowsForRun({ rows: rawRows, sessionFile, runDirectory: input.runDirectory, runId: input.runId, }); if (attributed.kind === "missing-boundary") { missingSources.push("session-timeline"); reasons.push(attributed.reason); } else { admitAttributed(attributed.rows); } } catch (error) { // Single parse kernel: attribute the prefix, then decide by binding closure. // Closed shared interval → full frame/model/tool (damage belongs to a later run). // Open interval / private volume / missing boundary → honest missing + partial edges. if (error instanceof LedgerSessionJsonlError) { const attributed = attributeSessionRowsForRun({ rows: error.prefixRows, sessionFile, runDirectory: input.runDirectory, runId: input.runId, }); if (attributed.kind === "attributed" && attributed.closed) { admitAttributed(attributed.rows); } else if (attributed.kind === "attributed") { missingSources.push("session-timeline"); reasons.push(errorText(error)); const admitted = admitFrameSpanFromRows(attributed.rows); models = admitted.models; partialFirstFrameAt = admitted.partialFirstFrameAt; partialLastFrameAt = admitted.partialLastFrameAt; } else { missingSources.push("session-timeline"); reasons.push(errorText(error)); // missing-boundary on damaged prefix: no partial ownership of sibling runs. } } else { missingSources.push("session-timeline"); reasons.push(errorText(error)); } } // 2) tool association (only when session rows are available) if (rows !== undefined && !missingSources.includes("session-timeline")) { try { toolIntervals = extractSessionToolIntervals(rows); } catch (error) { missingSources.push("tool-association"); reasons.push(errorText(error)); } } // 3) typed terminal artifact — absence is no-receipt only for terminal runs. // Live admitted/running/resumable runs (existing run-state) are not dead legs. // #855: writer-lease ghost check is independent of terminal artifact status. // present → still emit metrics; unreadable → still enter missingSources; // admitted|running + non-live lease → still list in ghostLegs (ticket #4). const lifecycle = await readExistingRunLifecycleState(input.runDirectory); let ghostLeg: AnalystGhostLeg | undefined; if (lifecycle === "admitted" || lifecycle === "running") { const ghost = await classifyGhostCandidate({ runId: input.runId, runState: lifecycle, runDirectory: input.runDirectory, }); if (ghost.kind === "ghost") ghostLeg = ghost.leg; } try { const artifact = await readRunTerminalArtifact(input.runDirectory); if (artifact.status === "unreadable") { missingSources.push("terminal-artifact"); reasons.push(`${artifact.file}: ${artifact.reason}`); } else if (artifact.status === "absent") { if (lifecycle === "admitted" || lifecycle === "running") { // No terminal yet: ghost report only, or live in-flight omit. if (ghostLeg !== undefined) return { kind: "ghost", leg: ghostLeg }; return { kind: "live" }; } if (lifecycle !== undefined && LIVE_RUN_STATES.has(lifecycle)) { return { kind: "live" }; } terminal = { status: "absent" }; } else { // role already required nonblank by readRunTerminalArtifact (single owner). terminal = { status: "present", file: artifact.file, body: artifact.body, role: artifact.body.role as string, }; } } catch (error) { missingSources.push("terminal-artifact"); reasons.push(errorText(error)); } if (missingSources.length > 0) { // Prefer full-span edges when session was admitted; else partial prefix facts. const firstFrameAt: AnalystFirstFrameAt = frameSpan !== undefined ? { status: "present", at: frameSpan.startedAt } : partialFirstFrameAt; const lastFrameAt: AnalystOptionalTimestamp = frameSpan !== undefined ? { status: "present", at: frameSpan.endedAt } : partialLastFrameAt; return { kind: "unreadable", entry: { runId: input.runId, book: input.book, missingSources, reason: reasons.join("; "), firstFrameAt, lastFrameAt, }, ...(ghostLeg !== undefined ? { ghostLeg } : {}), }; } // All three faces present after the gate above (TypeScript cannot see the link). if (frameSpan === undefined || toolIntervals === undefined || terminal === undefined) { throw new Error( `classifyScopedRun internal invariant: missing retained facts for ${input.runId}`, ); } // Nested auditor-roles gate pairs stay inside the sole scan (families must // not readdir this tree again). Missing directory → []. Damaged discovered // nested JSONL is page-local unreadable — never silently under-count rounds. let gateCycles: readonly AnalystGateCycleRound[]; try { const parentSessionFile = join(input.runDirectory, "session", "session.jsonl"); gateCycles = await readAnalystGateCyclesFromAuditorRoles( join(input.runDirectory, "session", "auditor-roles"), { parentSessionFile }, ); } catch (error) { return { kind: "unreadable", entry: { runId: input.runId, book: input.book, missingSources: ["auditor-roles"], reason: errorText(error), firstFrameAt: { status: "present", at: frameSpan.startedAt }, lastFrameAt: { status: "present", at: frameSpan.endedAt }, }, ...(ghostLeg !== undefined ? { ghostLeg } : {}), }; } return { kind: "readable", facts: { runId: input.runId, book: input.book, role: input.role, frameSpan, toolIntervals, terminal, models, gateCycles, }, ...(ghostLeg !== undefined ? { ghostLeg } : {}), }; } /** * Scan ledger home books//{subject/}runs for runs in the issue scope. * Walk = listBookRunDirectories (flat legacy + subject-tree; #859). * #399: scope = book × optional ticket. * - bookKey set → that book only; whole-book when no ticket and no projectRoot; * ticket filters alone; bookKey + projectRoot (cohort ensure, #412) narrows * to that root inside the book. * - projectRoot without bookKey (sweep/legacy): git-resolved → whole that book; * non-git → path-narrow across books (fixture isolation). * Damaged required sources become unreadable exclusions. * Readable runs retain typed facts for metric-family composition. */ export async function scanAnalystIssueRuns(input: { /** Explicit book key — CLI issue query always supplies this. */ readonly bookKey?: string; /** Caller typed ticket face — when set, only matching invocation.ticketNumber admits. */ readonly ticketNumber?: number; /** * Sweep/legacy path-narrow pointer. Not a CLI issue-query face (#399 deleted). * When bookKey absent: git common-dir → whole book; else path filter. */ readonly projectRoot?: string; readonly home?: string; }): Promise { // Package-owned machine home only (ADR 0048) — caller may supply explicit home for isolation. const ledgerHome = resolveActivationLedgerHome(input.home); const scopeTicketNumber = input.ticketNumber; const booksRoot = join(ledgerHome, "books"); let wholeBook = false; let scopeRootIdentity: string | undefined; let bookNames: string[]; if (input.bookKey !== undefined && input.bookKey.trim() !== "") { bookNames = [input.bookKey]; if (input.projectRoot !== undefined) { // Cohort ensure conjunction (#412): the index join already selected the // row — a cache-miss recompute must stay inside this root of this book, // never inhale sibling roots' runs into one issue page. wholeBook = false; scopeRootIdentity = physicalPathIdentity(input.projectRoot); } else { // CLI book scope: whole book unless ticket filters. Never path-narrow. wholeBook = true; } } else if (input.projectRoot !== undefined) { const resolved = tryResolveBookKeyFromProjectRoot(input.projectRoot); if (resolved !== undefined) { bookNames = [resolved]; wholeBook = true; } else { bookNames = await listLedgerBookNames(booksRoot); scopeRootIdentity = physicalPathIdentity(input.projectRoot); } } else { bookNames = await listLedgerBookNames(booksRoot); wholeBook = true; } if (bookNames.length === 0) { return { runs: [], unreadable: [], scopeConflicts: [], ghostLegs: [] }; } const runs: AnalystReadableRunFacts[] = []; const unreadable: AnalystUnreadableRun[] = []; const ghostLegs: AnalystGhostLeg[] = []; // scopeConflicts retained on the scan face for page envelope compat; book×ticket // scope no longer emits projectRoot dual-key conflicts on the CLI path. const scopeConflicts: AnalystScopeConflict[] = []; for (const book of bookNames) { const bookDir = join(booksRoot, book); let runDirectories: string[]; try { runDirectories = await listBookRunDirectories(bookDir); } catch (error) { if (isMissingPathError(error)) continue; throw error; } for (const runDirectory of runDirectories) { const runName = basename(runDirectory); const parsed = parseRunDirectoryName(runName); if (parsed === undefined) continue; let scopeFields: InvocationScopeFields | undefined; try { scopeFields = await readInvocationScopeFields(runDirectory); } catch (error) { // Corrupt invocation cannot be scoped to this issue — skip. if (error instanceof SyntaxError) continue; throw error; } if (scopeFields === undefined) continue; const runProjectRootIdentity = physicalPathIdentity(scopeFields.projectRoot); const decision = decideIssueScope({ scopeTicketNumber, wholeBook, scopeRootIdentity, runProjectRootIdentity, runTicketNumber: scopeFields.ticketNumber, }); if (!decision.inScope) continue; const classified = await classifyScopedRun({ book, runId: parsed.runId, role: parsed.role, runDirectory, }); if (classified.kind === "readable") { runs.push(classified.facts); if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg); } else if (classified.kind === "unreadable") { unreadable.push(classified.entry); if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg); } else if (classified.kind === "ghost") { ghostLegs.push(classified.leg); } // live in-flight runs are omitted from legs and unreadable (not failure/death). // #855 ghost legs are reported separately — not counted as in-flight. // Lease check is independent of terminal artifact: present/unreadable may // still carry a ghostLeg alongside metrics or missingSources. } } return { runs, unreadable, scopeConflicts, ghostLegs, }; }