@agent-delivery-harness/cli 0.5.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,11 +20,13 @@
20
20
  */
21
21
  import {
22
22
  RUN_JOURNAL_REQUIRED_ENTRIES,
23
+ explainRunJournal,
23
24
  runJournalCarries,
24
25
  runPrimaryTicket,
25
26
  projectRunActivities,
26
27
  type RunEvent,
27
28
  type RunJournalEvaluation,
29
+ type RunJournalExplanation,
28
30
  type RunJournalRequiredEntry,
29
31
  } from "@agent-delivery-harness/kernel";
30
32
  import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
@@ -152,6 +154,8 @@ export function detailOf(event: RunEvent): string {
152
154
  return `${oneLineOf(payload["outcome"])}${payload["reference"] === undefined ? "" : ` — ${oneLineOf(payload["reference"])}`}`;
153
155
  case "context.saved":
154
156
  return `stage ${oneLineOf(payload["stage"])} (observation only)`;
157
+ case "action.reconciled":
158
+ return `${oneLineOf(payload["actionId"])} reconciled ${oneLineOf(payload["outcome"])} (host-attested) evidence ${oneLineOf(payload["evidenceReference"])}`;
155
159
  case "action.intent":
156
160
  case "action.observed":
157
161
  return `${oneLineOf(payload["actionId"])} ${oneLineOf(payload["outcome"] ?? "unknown")} reference ${oneLineOf(payload["reference"])}`;
@@ -225,6 +229,174 @@ export function roundRows(events: readonly RunEvent[]): readonly string[] {
225
229
  });
226
230
  }
227
231
 
232
+ // ── Cycle time ───────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * A span of seconds as one short label, for a column an operator scans.
236
+ *
237
+ * WHY A LABEL AND NOT THE NUMBER. `runs list` prints one row per run and the
238
+ * question it answers is "which of these took a working day"; 47_112 answers
239
+ * that only after arithmetic. The machine-readable surfaces carry the seconds
240
+ * themselves, so nothing is lost: `durationSeconds` is beside this everywhere
241
+ * the label is serialized.
242
+ *
243
+ * The smaller unit is zero-padded so the column aligns under a proportional-
244
+ * enough terminal font, and the largest unit is never dropped: a run of 104
245
+ * hours reads `104h 03m` rather than being folded into days, because a journal
246
+ * spans hours and a day boundary is not a thing this file knows about.
247
+ */
248
+ export function durationLabel(seconds: number): string {
249
+ const whole = Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds) : 0;
250
+ const hours = Math.floor(whole / 3600);
251
+ const minutes = Math.floor((whole % 3600) / 60);
252
+ const rest = whole % 60;
253
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
254
+ if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`;
255
+ return `${rest}s`;
256
+ }
257
+
258
+ /**
259
+ * The gate time a journal can actually account for.
260
+ *
261
+ * `counted` is how many gate completions were journaled and `totalMs` their
262
+ * sum; `unseen` says the journal carries none at all. That last one is the
263
+ * honest answer to the thing this ticket was written for: a delivery whose gate
264
+ * ran outside the CLI wrapper and emitted no `gate.reported` has a tail that
265
+ * looks free, and a zero printed with no label is indistinguishable from a gate
266
+ * that took no time. `unreadable` counts a gate completion whose `durationMs`
267
+ * could not be read as a number, so a partial sum is never passed off as whole.
268
+ *
269
+ * EVERY journaled gate is summed, including one superseded by a re-run. The
270
+ * headline outcome takes the last completion (a re-run supersedes its verdict),
271
+ * but the time is time: a delivery that gated twice spent both.
272
+ */
273
+ export interface RunGateTime {
274
+ readonly totalMs: number;
275
+ readonly counted: number;
276
+ readonly unreadable: number;
277
+ readonly unseen: boolean;
278
+ }
279
+
280
+ /**
281
+ * The run's three phases, in seconds, plus what they were spent on.
282
+ *
283
+ * The boundaries are the journal's own events, so the three spans tile the
284
+ * run's whole span and sum back to `durationSeconds` within per-phase rounding:
285
+ *
286
+ * - implementation: the first event to the first round event;
287
+ * - review: that first round event to the last `review.round.closed`, which
288
+ * deliberately INCLUDES the fix time between rounds — an operator asking what
289
+ * review cost is asking what the loop cost, not what the reviewers were busy;
290
+ * - tail: the last closed round to the last event, which is `run.ended` on an
291
+ * ended run and whatever the run has reached on an open one.
292
+ *
293
+ * A run with no round event has no review and no tail: implementation is the
294
+ * whole span, which is what a delivery that never opened a round actually did.
295
+ */
296
+ export interface RunPhases {
297
+ readonly implementationSeconds: number;
298
+ readonly reviewSeconds: number;
299
+ readonly tailSeconds: number;
300
+ /** Logical rounds the journal mentions, opened or closed. */
301
+ readonly rounds: number;
302
+ readonly gate: RunGateTime;
303
+ }
304
+
305
+ /** The two kinds that bound the review phase. */
306
+ const ROUND_KINDS: ReadonlySet<string> = new Set(["review.round.opened", "review.round.closed"]);
307
+
308
+ /**
309
+ * An instant held inside `[low, high]`, so the phases tile the span.
310
+ *
311
+ * The journal is append-only and its instants are written by whoever holds the
312
+ * clock, which is not one clock: an executor on a laptop and a CLI inside a
313
+ * container can disagree by seconds. Without this, one skewed `at` gives a
314
+ * negative span — reported as zero by `spanSeconds` — and the three phases
315
+ * silently stop summing to the total. Clamping keeps the arithmetic true and
316
+ * loses only the skew, which was never measurable anyway. An unparseable
317
+ * instant is treated as `high`, the same way a missing one is.
318
+ */
319
+ function heldWithin(value: string | undefined, low: string, high: string): string {
320
+ const at = value === undefined ? Number.NaN : Date.parse(value);
321
+ if (!Number.isFinite(at)) return high;
322
+ const lowAt = Date.parse(low);
323
+ const highAt = Date.parse(high);
324
+ if (!Number.isFinite(lowAt) || !Number.isFinite(highAt)) return high;
325
+ if (at < lowAt) return low;
326
+ if (at > highAt) return high;
327
+ return value as string;
328
+ }
329
+
330
+ /** A gate completion's duration, or nothing when it cannot be read as one. */
331
+ const gateDurationOf = (event: RunEvent): number | undefined => {
332
+ const value = payloadOf(event)["durationMs"];
333
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
334
+ };
335
+
336
+ /**
337
+ * The gate completions a journal carries: the CLI's own for the `gate` command,
338
+ * and the executor's `gate.reported` for a repository gate that is not a
339
+ * product command. Both are counted because they are different gates, not two
340
+ * writers' accounts of one — an adopter whose `npm run check` is not a product
341
+ * command journals only the second.
342
+ */
343
+ export function projectGateTime(events: readonly RunEvent[]): RunGateTime {
344
+ const gates = events.filter(
345
+ (event) =>
346
+ event.kind === "gate.reported" ||
347
+ (event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === "gate"),
348
+ );
349
+ let totalMs = 0;
350
+ let unreadable = 0;
351
+ for (const gate of gates) {
352
+ const duration = gateDurationOf(gate);
353
+ if (duration === undefined) unreadable += 1;
354
+ else totalMs += duration;
355
+ }
356
+ return { totalMs, counted: gates.length, unreadable, unseen: gates.length === 0 };
357
+ }
358
+
359
+ /** The run's phase breakdown, derived from the journal and nothing else. */
360
+ export function projectRunPhases(events: readonly RunEvent[]): RunPhases {
361
+ const startedAt = events[0]?.at ?? "";
362
+ const lastAt = events[events.length - 1]?.at ?? "";
363
+ const reviewStart = heldWithin(events.find((event) => ROUND_KINDS.has(event.kind))?.at, startedAt, lastAt);
364
+ const reviewEnd = heldWithin(events.findLast((event) => event.kind === "review.round.closed")?.at, reviewStart, lastAt);
365
+ return {
366
+ implementationSeconds: spanSeconds(startedAt, reviewStart),
367
+ reviewSeconds: spanSeconds(reviewStart, reviewEnd),
368
+ tailSeconds: spanSeconds(reviewEnd, lastAt),
369
+ rounds: roundEntries(events).length,
370
+ gate: projectGateTime(events),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * The phase breakdown as text rows, shared by every surface that prints it.
376
+ *
377
+ * The total row repeats `durationSeconds` rather than re-deriving it, so a
378
+ * reader can check the three phases against the one figure `runs list` printed
379
+ * for the same run; `(open)` is on the total because an open run's last event
380
+ * is not an ending and the tail is still accruing.
381
+ */
382
+ export function phaseRows(events: readonly RunEvent[]): readonly string[] {
383
+ const summary = summarize(events);
384
+ const phases = summary.phases;
385
+ const gate = phases.gate;
386
+ const gateRow = gate.unseen
387
+ ? "unseen — no gate completion is journaled, so any gate this delivery ran is missing from the tail above"
388
+ : `${durationLabel(gate.totalMs / 1000)} summed over ${gate.counted} journaled gate completion(s)` +
389
+ (gate.unreadable === 0 ? "" : `; ${gate.unreadable} journaled no readable duration, so the sum under-reports`);
390
+ return [
391
+ " phases:",
392
+ ` implementation ${durationLabel(phases.implementationSeconds)} (first event to the first round)`,
393
+ ` review ${durationLabel(phases.reviewSeconds)} over ${phases.rounds} round(s), fix time between rounds included`,
394
+ ` tail ${durationLabel(phases.tailSeconds)} (last closed round to the last event)`,
395
+ ` gate time ${gateRow}`,
396
+ ` total ${durationLabel(summary.durationSeconds)}${summary.open ? " (open; the tail is still accruing)" : ""}`,
397
+ ];
398
+ }
399
+
228
400
  // ── The readout ──────────────────────────────────────────────────────────────
229
401
 
230
402
  export interface Readout {
@@ -232,6 +404,12 @@ export interface Readout {
232
404
  readonly present: readonly RunJournalRequiredEntry[];
233
405
  readonly missing: readonly RunJournalRequiredEntry[];
234
406
  readonly violations: readonly string[];
407
+ /**
408
+ * One per violation above, in the same order. The readout evaluates unbound
409
+ * to any record, so these explain the journal's own ordering only; the
410
+ * record-bound explanations are `verify`'s.
411
+ */
412
+ readonly explanations?: readonly RunJournalExplanation[];
235
413
  /** The config-presence note, present on exactly the status that it explains. */
236
414
  readonly note?: string;
237
415
  }
@@ -255,11 +433,16 @@ export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEva
255
433
  evaluation.status === "complete-executor-only" && rootDir !== undefined && harnessConfigPresentAt(rootDir)
256
434
  ? `no CLI gate completion in this journal; harness.config.ts present at ${oneLine(rootDir, 400)}`
257
435
  : undefined;
436
+ // Explained with the arguments this readout is taken with — none — so the
437
+ // sentences describe exactly the violations printed beside them. `verify`
438
+ // supplies a record and gets the record-bound explanations instead.
439
+ const explanations = evaluation.violations.length === 0 ? undefined : explainRunJournal(events).explanations;
258
440
  return {
259
441
  status: evaluation.status,
260
442
  present,
261
443
  missing: evaluation.missing,
262
444
  violations: evaluation.violations,
445
+ ...(explanations === undefined ? {} : { explanations }),
263
446
  ...(note === undefined ? {} : { note }),
264
447
  };
265
448
  }
@@ -276,7 +459,13 @@ export function readoutRows(events: readonly RunEvent[], evaluation: RunJournalE
276
459
  ` present: ${readout.present.length === 0 ? "(none)" : readout.present.join(", ")}`,
277
460
  ` missing: ${readout.missing.length === 0 ? "(none)" : [...readout.missing].join(", ")}`,
278
461
  ];
279
- if (readout.violations.length > 0) rows.push(` violations: ${[...readout.violations].join(", ")}`);
462
+ if (readout.violations.length > 0) {
463
+ rows.push(` violations: ${[...readout.violations].join(", ")}`);
464
+ for (const explanation of readout.explanations ?? []) {
465
+ rows.push(` ${explanation.violation}: ${oneLine(explanation.because, 400)}`);
466
+ }
467
+ rows.push(" admission: none of the above blocks admission; no gate, admission, or record decision reads a journal.");
468
+ }
280
469
  if (readout.note !== undefined) rows.push(` note: ${readout.note}`);
281
470
  return rows;
282
471
  }
@@ -311,6 +500,13 @@ export interface RunSummary {
311
500
  * is too.
312
501
  */
313
502
  readonly durationSeconds: number;
503
+ /**
504
+ * The same span, divided into the three phases a delivery is actually made
505
+ * of. Derived from the events beside it, never carried by a journal: an
506
+ * archive written before this member existed is projected with it, and one
507
+ * carrying invented phases is re-derived over its own events.
508
+ */
509
+ readonly phases: RunPhases;
314
510
  readonly roundsOpened: number;
315
511
  readonly roundsClosed: number;
316
512
  readonly findings: { readonly P0: number; readonly P1: number; readonly P2: number; readonly P3: number };
@@ -379,6 +575,7 @@ export function summarize(events: readonly RunEvent[]): RunSummary {
379
575
  startedAt,
380
576
  lastAt,
381
577
  durationSeconds: spanSeconds(startedAt, lastAt),
578
+ phases: projectRunPhases(events),
382
579
  roundsOpened: events.filter((event) => event.kind === "review.round.opened").length,
383
580
  roundsClosed: closed.length,
384
581
  findings: {
package/src/run-server.ts CHANGED
@@ -320,6 +320,15 @@ const EMPTY_SUMMARY: RunSummary = {
320
320
  startedAt: "",
321
321
  lastAt: "",
322
322
  durationSeconds: 0,
323
+ // Nothing was read, so every phase is empty and the gate is unseen rather
324
+ // than summed to zero: an unreadable journal reports no gate, not a free one.
325
+ phases: {
326
+ implementationSeconds: 0,
327
+ reviewSeconds: 0,
328
+ tailSeconds: 0,
329
+ rounds: 0,
330
+ gate: { totalMs: 0, counted: 0, unreadable: 0, unseen: true },
331
+ },
323
332
  roundsOpened: 0,
324
333
  roundsClosed: 0,
325
334
  findings: { P0: 0, P1: 0, P2: 0, P3: 0 },
@@ -49,6 +49,7 @@ import {
49
49
  createRunStore,
50
50
  sha256Hex,
51
51
  evaluateRunJournal,
52
+ explainRunJournal,
52
53
  gitNamespaceClearedEnvironment,
53
54
  neutralizeForDisplay,
54
55
  resolveRunStoreLocation,
@@ -56,6 +57,7 @@ import {
56
57
  type Blocker,
57
58
  type RunEventInput,
58
59
  type RunEventKind,
60
+ type RunJournalRoundBinding,
59
61
  type RunJournalRow,
60
62
  type RunStore,
61
63
  } from "@agent-delivery-harness/kernel";
@@ -266,6 +268,8 @@ export async function resolveRunJournalRow(input: {
266
268
  const read = await resolved.surface.store.read(match.runId);
267
269
  if (!read.ok) return ABSENT;
268
270
  const evaluation = evaluateRunJournal(read.events, input.treeSha, input.mandatedLensIds, acceptedTrees.slice(1));
271
+ // The same arguments, so the explanations describe exactly these violations.
272
+ const diagnostics = explainRunJournal(read.events, input.treeSha, input.mandatedLensIds, acceptedTrees.slice(1));
269
273
  const alsoMatching = [...new Set(matches.flatMap((entry) => entry === undefined ? [] : [entry.runId, ...entry.alsoMatching]))]
270
274
  .filter((runId) => runId !== match.runId);
271
275
  return {
@@ -273,12 +277,112 @@ export async function resolveRunJournalRow(input: {
273
277
  ...(alsoMatching.length === 0 ? {} : { alsoMatching }),
274
278
  status: evaluation.status,
275
279
  missing: evaluation.missing,
276
- ...(evaluation.violations.length === 0 ? {} : { violations: evaluation.violations }),
280
+ ...(evaluation.violations.length === 0 ? {} : { violations: evaluation.violations, explanations: diagnostics.explanations }),
277
281
  ...(acceptedTrees.length === 1 ? {} : { recordTreeSha: input.treeSha, reviewedCandidateTreeShas: acceptedTrees.slice(1) }),
282
+ ...(diagnostics.roundBinding === undefined ? {} : { roundBinding: diagnostics.roundBinding }),
278
283
  attestation: "self",
279
284
  };
280
285
  }
281
286
 
287
+ /**
288
+ * The instants a delivery record stamps: the journal's own first and last.
289
+ *
290
+ * `endedAt` is the last instant the journal had REACHED when it was read, not
291
+ * the run's ending — `record` runs long before `run.ended`, and a record that
292
+ * waited for the run to end would never be written. So the pair is the span the
293
+ * delivery had spent by the time it recorded, which is the figure the record is
294
+ * for; `runs show` is where the run's own ending is read.
295
+ */
296
+ export interface RunSpanRow {
297
+ readonly runId: string;
298
+ readonly startedAt: string;
299
+ readonly endedAt: string;
300
+ }
301
+
302
+ /**
303
+ * The span of the journal that describes this candidate, or nothing.
304
+ *
305
+ * FOUND BY TREE SHA FIRST, for the reason `resolveRunJournalRow` is: a journal
306
+ * bound to this candidate is one that provably describes it, and by verify time
307
+ * the run has usually ended and cleared the worktree pointer. The pointer is a
308
+ * FALLBACK and only where the caller says so, because the two callers need
309
+ * different things from a miss. `record` is stamping: it runs mid-run, before
310
+ * anything need have journaled this exact tree, and the worktree's current run
311
+ * is the run it is recording from — a best-effort stamp is better than none.
312
+ * `verify` is refusing: a refusal must rest on a journal that binds the
313
+ * candidate the record binds, never on whichever run happens to be current in
314
+ * the checkout someone verified from, so it asks without the fallback and
315
+ * reports the miss as unchecked.
316
+ *
317
+ * MORE THAN ONE RUN CAN BIND ONE CANDIDATE, and the store says so: a delivery
318
+ * that recorded in run A and is verified from a later run B in the same
319
+ * worktree has two journals naming that tree, and `findByCandidateTreeSha`
320
+ * returns the most recently started one with the rest in `alsoMatching`.
321
+ * Refusing on whichever sorted first would block an honest record whose span
322
+ * run A reports exactly. So a caller that already knows the span it is checking
323
+ * passes `preferStartedAt`, and the matching run whose journal starts there is
324
+ * the one returned; only where NO matching run starts there does the
325
+ * top-ranked run stand, which is the case a refusal may rest on.
326
+ *
327
+ * Every failure is nothing found: no store, no match, an unreadable journal, an
328
+ * empty one. Nothing here decides what a miss means.
329
+ */
330
+ export async function resolveRunSpan(input: {
331
+ readonly cwd: string;
332
+ readonly treeSha: string;
333
+ readonly allowCurrentRun?: boolean;
334
+ readonly preferStartedAt?: string;
335
+ }): Promise<RunSpanRow | undefined> {
336
+ const resolved = await resolveRunSurface(input.cwd);
337
+ if (!resolved.ok) return undefined;
338
+ const matched = await resolved.surface.store.findByCandidateTreeSha(input.treeSha);
339
+ const runIds: string[] = matched === undefined ? [] : [matched.runId, ...matched.alsoMatching];
340
+ if (runIds.length === 0 && input.allowCurrentRun === true) {
341
+ const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
342
+ if (current.ok && current.runId !== undefined) runIds.push(current.runId);
343
+ }
344
+ let fallback: RunSpanRow | undefined;
345
+ for (const runId of runIds) {
346
+ const read = await resolved.surface.store.read(runId);
347
+ if (!read.ok) continue;
348
+ const startedAt = read.events[0]?.at;
349
+ const endedAt = read.events[read.events.length - 1]?.at;
350
+ if (startedAt === undefined || endedAt === undefined) continue;
351
+ const row: RunSpanRow = { runId, startedAt, endedAt };
352
+ if (input.preferStartedAt === undefined || startedAt === input.preferStartedAt) return row;
353
+ fallback ??= row;
354
+ }
355
+ return fallback;
356
+ }
357
+
358
+ /**
359
+ * What the round binding means to someone deciding whether to act on the row.
360
+ *
361
+ * The `reviewed-tree` sentence is the one this ticket's readout exists for: it
362
+ * names WHERE the acceptance came from — the record's own verified evidence —
363
+ * and says in the same breath that the two trees differ, because the row prints
364
+ * both and an operator who reads "accepted" as "equal" would then wonder why.
365
+ */
366
+ const ROUND_BINDING_ROWS: Readonly<Record<RunJournalRoundBinding, string>> = {
367
+ "record-tree": "the governing closed round binds the record's own candidate tree",
368
+ "reviewed-tree":
369
+ "the governing closed round binds a reviewed candidate above, accepted from this record's verified review-neutral projection; the two trees differ and neither is claimed to equal the other",
370
+ unbound: "no closed round binds the record's candidate tree or any reviewed candidate it accepts",
371
+ };
372
+
373
+ /**
374
+ * The one true sentence about authority, printed wherever a violation is.
375
+ *
376
+ * An operator reading a list of violated constraints under a line that says
377
+ * `verified` is being asked a question the row cannot answer by itself: does
378
+ * this stop anything? It does not. The journal is appended to by anything the
379
+ * owner can execute, so no admission, gate, or record decision reads it — and
380
+ * the only thing that can be blocked by it is this command, behind the local
381
+ * opt-in the operator typed themselves.
382
+ */
383
+ export const RUN_JOURNAL_ADMISSION_ROW =
384
+ "none of the above blocks admission: the record's own evidence decides that, and no gate, admission, or record decision reads a journal. Only --require-run-journal blocks, and only this verify invocation.";
385
+
282
386
  /**
283
387
  * The row, rendered for a terminal. Every store-derived string goes through
284
388
  * `oneLine` for the same reason the viewer's rows do: a run id or a constraint
@@ -295,9 +399,19 @@ export function runJournalRows(row: RunJournalRow): readonly string[] {
295
399
  rows.push(` record candidate: ${oneLine(row.recordTreeSha, 64)}`);
296
400
  rows.push(` reviewed candidate: ${row.reviewedCandidateTreeShas.map((tree) => oneLine(tree, 64)).join(", ")} (verified review-neutral projection)`);
297
401
  }
402
+ if (row.roundBinding !== undefined) rows.push(` round binding: ${ROUND_BINDING_ROWS[row.roundBinding]}`);
298
403
  rows.push(` missing: ${row.missing.length === 0 ? "(none)" : row.missing.map((entry) => oneLine(entry, 64)).join(", ")}`);
299
404
  if (row.violations !== undefined && row.violations.length > 0) {
300
405
  rows.push(` violations: ${row.violations.map((entry) => oneLine(entry, 64)).join(", ")}`);
406
+ // One indented line per violation, in the order they were raised, each
407
+ // saying why it exists and — where it is only another warning restated —
408
+ // which warning that is. Product-authored text, bounded like every other
409
+ // store-derived row for the same reason.
410
+ for (const explanation of row.explanations ?? []) {
411
+ const consequence = explanation.consequenceOf === undefined ? "" : `; a consequence of ${oneLine(explanation.consequenceOf, 64)}, not a separate mistake`;
412
+ rows.push(` ${oneLine(explanation.violation, 64)}: ${oneLine(explanation.because, 400)}${consequence}`);
413
+ }
414
+ rows.push(` admission: ${RUN_JOURNAL_ADMISSION_ROW}`);
301
415
  }
302
416
  return rows;
303
417
  }
@@ -0,0 +1,86 @@
1
+ /** Durable per-check history; allocation order, never completion time, owns precedence. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { link, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { digestCanonical, type ScopedCheckAttempt } from "@agent-delivery-harness/kernel";
6
+ import { CheckSnapshotError } from "./check-snapshot.ts";
7
+ import type { RecordedScopedAttemptDiagnostic } from "./scoped-diagnostics.ts";
8
+ export interface AttemptPayload {
9
+ readonly outputs: readonly { readonly path: string; readonly base64: string; readonly sha256: string }[];
10
+ readonly durationMs?: number;
11
+ readonly dependencyDigest?: string;
12
+ readonly log?: string;
13
+ readonly diagnostic?: RecordedScopedAttemptDiagnostic;
14
+ }
15
+ export interface StoredAttempt { readonly attempt: ScopedCheckAttempt; readonly payload?: AttemptPayload }
16
+ const corrupt = () => new CheckSnapshotError("check_attempt_corrupt", "Scoped check attempt history is missing, corrupt or inconsistent.");
17
+ export class AttemptStore {
18
+ readonly root: string;
19
+ constructor(root: string) { this.root = root; }
20
+ private async generations(): Promise<number[]> {
21
+ let names: string[];
22
+ try { names = await readdir(this.root); }
23
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
24
+ if (names.some(n => !/^[1-9][0-9]*$/.test(n) || !Number.isSafeInteger(Number(n)))) throw corrupt();
25
+ return names.map(Number).sort((a, b) => a - b);
26
+ }
27
+ async allocate(input: Omit<ScopedCheckAttempt, "attemptId" | "generation" | "status">): Promise<ScopedCheckAttempt> {
28
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
29
+ let generation = Math.max(0, ...await this.generations()) + 1;
30
+ for (;;) {
31
+ const dir = path.join(this.root, String(generation));
32
+ try { await mkdir(dir, { mode: 0o700 }); }
33
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") { generation++; continue; } throw error; }
34
+ const attempt: ScopedCheckAttempt = { ...input, generation, attemptId: randomUUID(), status: "running" };
35
+ await this.publish(path.join(dir, "running.json"), { attempt });
36
+ return attempt;
37
+ }
38
+ }
39
+ private async publish(target: string, entry: StoredAttempt): Promise<void> {
40
+ const temp = `${target}.${randomUUID()}.tmp`;
41
+ try {
42
+ await writeFile(temp, JSON.stringify({ digest: digestCanonical(entry), entry }), { flag: "wx", mode: 0o600 });
43
+ // Linking a complete private file publishes atomically without replacing an
44
+ // already-published terminal result from this or a competing invocation.
45
+ await link(temp, target);
46
+ } finally { await unlink(temp).catch(() => undefined); }
47
+ }
48
+ async finish(attempt: ScopedCheckAttempt, status: "passed" | "failed" | "interrupted", payload: AttemptPayload): Promise<void> {
49
+ const rows = await this.read();
50
+ const running = rows.find(row => row.attempt.generation === attempt.generation)?.attempt;
51
+ if (running?.status !== "running" || digestCanonical(running) !== digestCanonical(attempt)) throw corrupt();
52
+ await this.publish(path.join(this.root, String(attempt.generation), "terminal.json"), { attempt: { ...attempt, status }, payload });
53
+ }
54
+ private async load(file: string): Promise<StoredAttempt> {
55
+ const raw = JSON.parse(await readFile(file, "utf8")) as { digest?: string; entry?: StoredAttempt };
56
+ const row = raw.entry;
57
+ const attempt = row?.attempt;
58
+ const nonempty = (value: unknown): value is string => typeof value === "string" && value.length > 0;
59
+ const candidate = attempt?.origin?.candidate;
60
+ if (!row || !attempt || raw.digest !== digestCanonical(row) || attempt.version !== "scoped-attempt/1" ||
61
+ !nonempty(attempt.attemptId) || !nonempty(attempt.providerId) || !nonempty(attempt.origin?.runId) ||
62
+ !Number.isSafeInteger(attempt.generation) || attempt.generation < 1 ||
63
+ !["running", "passed", "failed", "interrupted"].includes(attempt.status) ||
64
+ ![attempt.inputDigest, attempt.profileDigest].every(value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)) ||
65
+ !candidate || ![candidate.treeSha, candidate.deliverableDigest, candidate.identityToken, candidate.baseRef,
66
+ candidate.baseTipSha, candidate.mergeBaseSha, candidate.workspaceId].every(nonempty)) throw corrupt();
67
+ if (row.payload?.durationMs !== undefined && (typeof row.payload.durationMs !== "number" || !Number.isFinite(row.payload.durationMs) || row.payload.durationMs < 0)) throw corrupt();
68
+ return row;
69
+ }
70
+ async read(): Promise<StoredAttempt[]> {
71
+ try {
72
+ const rows: StoredAttempt[] = [];
73
+ for (const generation of await this.generations()) {
74
+ const dir = path.join(this.root, String(generation));
75
+ const start = await this.load(path.join(dir, "running.json"));
76
+ if (start.attempt.status !== "running" || start.attempt.generation !== generation) throw corrupt();
77
+ let terminal: StoredAttempt | undefined;
78
+ try { terminal = await this.load(path.join(dir, "terminal.json")); }
79
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
80
+ if (terminal && (terminal.attempt.status === "running" || digestCanonical({ ...terminal.attempt, status: "running" }) !== digestCanonical(start.attempt))) throw corrupt();
81
+ rows.push(terminal ?? start);
82
+ }
83
+ return rows;
84
+ } catch { throw corrupt(); }
85
+ }
86
+ }
@@ -0,0 +1,28 @@
1
+ /** Opt-in worktree capture through a private index; the author's staging is untouched. */
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { promisify } from "node:util";
7
+ import { captureGitCandidate, type CandidateCaptureOptions, type CandidateCommandRunner } from "@agent-delivery-harness/kernel";
8
+ const exec = promisify(execFile);
9
+ export async function captureScopedCandidate(options: CandidateCaptureOptions) {
10
+ const dir = await mkdtemp(path.join(tmpdir(), "delivery-index-"));
11
+ const env = { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), GIT_INDEX_FILE: path.join(dir, "index"), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
12
+ const run: CandidateCommandRunner = async (command, invocation) => {
13
+ try {
14
+ const result = await exec(command[0]!, command.slice(1), { cwd: invocation.cwd, env, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
15
+ return { exitCode: 0, stdout: result.stdout.toString("utf8"), ...(invocation.captureBytes ? { stdoutBase64: result.stdout.toString("base64") } : {}), stderr: result.stderr.toString("utf8") };
16
+ } catch (error) {
17
+ const failure = error as { code?: number; stdout?: Buffer; stderr?: Buffer };
18
+ return { exitCode: typeof failure.code === "number" ? failure.code : -1, stdout: failure.stdout?.toString("utf8") ?? "", stderr: failure.stderr?.toString("utf8") ?? "Git capture failed" };
19
+ }
20
+ };
21
+ try {
22
+ for (const args of [["read-tree", "HEAD"], ["add", "--all"]]) {
23
+ const result = await run(["git", ...args], { cwd: options.rootDir });
24
+ if (result.exitCode !== 0) throw new Error("Cannot capture source into a private index");
25
+ }
26
+ return await captureGitCandidate({ ...options, run });
27
+ } finally { await rm(dir, { recursive: true, force: true }); }
28
+ }