@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.
- package/package.json +3 -3
- package/src/boundary.ts +7 -2
- package/src/check-snapshot.ts +167 -0
- package/src/commands/gate.ts +33 -7
- package/src/commands/managed.ts +104 -34
- package/src/commands/prepare.ts +29 -4
- package/src/commands/record.ts +10 -1
- package/src/commands/runs.ts +53 -7
- package/src/commands/verify.ts +127 -2
- package/src/index.ts +3 -0
- package/src/main.ts +36 -18
- package/src/ordinary-context.ts +2 -18
- package/src/run-export.ts +40 -2
- package/src/run-projection.ts +198 -1
- package/src/run-server.ts +9 -0
- package/src/run-surface.ts +115 -1
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
package/src/run-projection.ts
CHANGED
|
@@ -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)
|
|
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 },
|
package/src/run-surface.ts
CHANGED
|
@@ -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
|
+
}
|