@agent-delivery-harness/cli 0.2.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/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +5 -5
- package/src/boundary.ts +84 -63
- package/src/check-snapshot.ts +167 -0
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +64 -13
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +105 -34
- package/src/commands/prepare.ts +148 -22
- package/src/commands/record.ts +107 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +461 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +156 -10
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +17 -1
- package/src/main.ts +56 -65
- package/src/ordinary-context.ts +83 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +202 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +327 -12
- package/src/run-server.ts +403 -149
- package/src/run-surface.ts +196 -9
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
- 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,9 +20,13 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import {
|
|
22
22
|
RUN_JOURNAL_REQUIRED_ENTRIES,
|
|
23
|
+
explainRunJournal,
|
|
23
24
|
runJournalCarries,
|
|
25
|
+
runPrimaryTicket,
|
|
26
|
+
projectRunActivities,
|
|
24
27
|
type RunEvent,
|
|
25
28
|
type RunJournalEvaluation,
|
|
29
|
+
type RunJournalExplanation,
|
|
26
30
|
type RunJournalRequiredEntry,
|
|
27
31
|
} from "@agent-delivery-harness/kernel";
|
|
28
32
|
import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
|
|
@@ -30,9 +34,91 @@ import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
|
|
|
30
34
|
/** The three labels every readout carries, so no reader mistakes this for evidence. */
|
|
31
35
|
export const READOUT_LABELS = "self-attested; observability, not evidence; unbound to a record";
|
|
32
36
|
|
|
37
|
+
/** A single progress view shared by renderers; callers supply their observation clock. */
|
|
38
|
+
export function projectRunProgress(events: readonly RunEvent[], now: string, freshnessWindowMs?: number) {
|
|
39
|
+
const projection = projectRunActivities(events, { now, ...(freshnessWindowMs === undefined ? {} : { freshnessWindowMs }) });
|
|
40
|
+
const latestFindings = new Map<string, (typeof projection.findings)[number]>();
|
|
41
|
+
for (const finding of projection.findings) {
|
|
42
|
+
if (finding.current) latestFindings.set(String(finding.payload["findingId"]), finding);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
asOf: now,
|
|
46
|
+
...projection,
|
|
47
|
+
currentFindings: [...latestFindings.values()],
|
|
48
|
+
findingsCoverage: events.some(e => e.kind === "finding.observed") ? "reported" as const : "unreported" as const,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function costLabel(value: unknown): string {
|
|
53
|
+
const cost = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
|
54
|
+
if (cost["coverage"] === "unreported" || cost["total"] === undefined) return "unreported";
|
|
55
|
+
const measured = `${oneLineOf(cost["total"])} ${oneLineOf(cost["unit"])}`.trim();
|
|
56
|
+
return cost["coverage"] === "partial" ? `${measured} (partial coverage)` : measured;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Aggregate only comparable review counters; the run-wide counter overlaps them. */
|
|
60
|
+
export function projectCosts(events: readonly RunEvent[]) {
|
|
61
|
+
const allClosed = events.filter((event) => event.kind === "review.round.closed");
|
|
62
|
+
// v2 round costs are reported cumulative snapshots. Repeated/reopened round
|
|
63
|
+
// snapshots cannot be summed as independent consumption. Keep the latest
|
|
64
|
+
// comparable value and mark coverage partial when supersession occurred.
|
|
65
|
+
const snapshots = new Map<string, RunEvent>();
|
|
66
|
+
for (const event of allClosed) {
|
|
67
|
+
const payload = payloadOf(event);
|
|
68
|
+
const cost = payload["cost"] as Record<string, unknown>;
|
|
69
|
+
const key = event.version === "run-event/2"
|
|
70
|
+
? JSON.stringify([payload["roundId"], cost["reportedBy"], cost["unit"] ?? "unreported"])
|
|
71
|
+
: `legacy-${event.seq}`;
|
|
72
|
+
snapshots.set(key, event);
|
|
73
|
+
}
|
|
74
|
+
const closed = [...snapshots.values()];
|
|
75
|
+
const totals = new Map<string, { unit: string; reportedBy: string; total: number | null }>();
|
|
76
|
+
let unreportedEntries = 0;
|
|
77
|
+
let partial = allClosed.length !== closed.length || events.filter((event) => event.kind === "review.round.opened").length !== closed.length;
|
|
78
|
+
for (const event of closed) {
|
|
79
|
+
const cost = payloadOf(event)["cost"] as Record<string, unknown>;
|
|
80
|
+
if (cost["coverage"] === "unreported") {
|
|
81
|
+
unreportedEntries += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (cost["coverage"] === "partial") partial = true;
|
|
85
|
+
const unit = cost["unit"] as string;
|
|
86
|
+
const reportedBy = cost["reportedBy"] as string;
|
|
87
|
+
const key = JSON.stringify([reportedBy, unit]);
|
|
88
|
+
const prior = totals.get(key);
|
|
89
|
+
const sum = prior?.total === null ? Infinity : (prior?.total ?? 0) + (cost["total"] as number);
|
|
90
|
+
// The validated event keeps the original finite measurement even if a sum
|
|
91
|
+
// exceeds the numeric range. Null is unavailable, never measured zero.
|
|
92
|
+
if (!Number.isFinite(sum)) partial = true;
|
|
93
|
+
totals.set(key, { unit, total: Number.isFinite(sum) ? sum : null, reportedBy });
|
|
94
|
+
}
|
|
95
|
+
const ended = events.find((event) => event.kind === "run.ended");
|
|
96
|
+
return {
|
|
97
|
+
review: {
|
|
98
|
+
coverage: totals.size === 0 ? "unreported" : partial || unreportedEntries > 0 ? "partial" : "complete",
|
|
99
|
+
unreportedEntries,
|
|
100
|
+
totals: [...totals.values()],
|
|
101
|
+
},
|
|
102
|
+
run: ended === undefined ? { coverage: "unreported" } : payloadOf(ended)["cost"],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
33
106
|
export const payloadOf = (event: RunEvent): Record<string, unknown> =>
|
|
34
107
|
typeof event.payload === "object" && event.payload !== null ? (event.payload as Record<string, unknown>) : {};
|
|
35
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The ticket an entry BOUND ITSELF to, as a row suffix, or nothing.
|
|
111
|
+
*
|
|
112
|
+
* Only the entry's own binding is rendered. An entry that omits `ticket` binds
|
|
113
|
+
* to the run's primary, which the runs table already names in the row above
|
|
114
|
+
* every timeline — repeating it on each unbound line would turn the one thing
|
|
115
|
+
* the member exists to say, that THIS entry belongs to THAT ticket, into
|
|
116
|
+
* boilerplate an operator learns to skip. The envelope is read rather than the
|
|
117
|
+
* payload because the validator holds the two to exact agreement and the
|
|
118
|
+
* envelope is what every other reader here reads.
|
|
119
|
+
*/
|
|
120
|
+
const boundTicket = (event: RunEvent): string => (event.ticket === undefined ? "" : ` for ${oneLineOf(event.ticket, 128)}`);
|
|
121
|
+
|
|
36
122
|
/**
|
|
37
123
|
* One row's detail for one event: enough to read the run without printing the
|
|
38
124
|
* whole payload back at the operator.
|
|
@@ -43,11 +129,11 @@ export function detailOf(event: RunEvent): string {
|
|
|
43
129
|
case "run.started":
|
|
44
130
|
return oneLineOf(payload["host"]) + (payload["displacedRunId"] === undefined ? "" : ` displaced ${oneLineOf(payload["displacedRunId"])}`);
|
|
45
131
|
case "run.ended":
|
|
46
|
-
return `${oneLineOf(payload["result"])} cost ${
|
|
132
|
+
return `${oneLineOf(payload["result"])} cost ${costLabel(payload["cost"])}`;
|
|
47
133
|
case "ticket.read":
|
|
48
134
|
return `${oneLineOf(payload["ticket"])} via ${oneLineOf(payload["tracker"])}`;
|
|
49
135
|
case "posture.declared":
|
|
50
|
-
return oneLineOf(payload["posture"]);
|
|
136
|
+
return oneLineOf(payload["posture"]) + boundTicket(event);
|
|
51
137
|
case "lens.selected":
|
|
52
138
|
return `mandated ${oneLineOf(payload["mandated"])} selected ${oneLineOf(payload["selected"])} — ${oneLineOf(payload["rationale"])}`;
|
|
53
139
|
case "review.round.opened":
|
|
@@ -57,15 +143,22 @@ export function detailOf(event: RunEvent): string {
|
|
|
57
143
|
case "command.completed":
|
|
58
144
|
return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
|
|
59
145
|
case "gate.reported":
|
|
60
|
-
return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
|
|
146
|
+
return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms${boundTicket(event)}`;
|
|
61
147
|
case "pr.opened":
|
|
62
|
-
return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}`;
|
|
148
|
+
return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}${boundTicket(event)}`;
|
|
63
149
|
case "blocker.recorded":
|
|
64
150
|
return `${oneLineOf(payload["code"])} — ${oneLineOf(payload["summary"])}`;
|
|
65
151
|
case "decision.recorded":
|
|
66
152
|
return `${oneLineOf(payload["fork"])} — ${oneLineOf(payload["choice"])}${payload["cited"] === undefined ? "" : ` (cited ${oneLineOf(payload["cited"])})`}`;
|
|
67
153
|
case "compounding.recorded":
|
|
68
154
|
return `${oneLineOf(payload["outcome"])}${payload["reference"] === undefined ? "" : ` — ${oneLineOf(payload["reference"])}`}`;
|
|
155
|
+
case "context.saved":
|
|
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"])}`;
|
|
159
|
+
case "action.intent":
|
|
160
|
+
case "action.observed":
|
|
161
|
+
return `${oneLineOf(payload["actionId"])} ${oneLineOf(payload["outcome"] ?? "unknown")} reference ${oneLineOf(payload["reference"])}`;
|
|
69
162
|
default:
|
|
70
163
|
return "";
|
|
71
164
|
}
|
|
@@ -109,6 +202,20 @@ export function roundEntries(events: readonly RunEvent[]): readonly RoundEntry[]
|
|
|
109
202
|
});
|
|
110
203
|
}
|
|
111
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The lenses a round carried, or the fact that it never opened.
|
|
207
|
+
*
|
|
208
|
+
* A round with no `review.round.opened` has no lens set to name, and the two
|
|
209
|
+
* surfaces have to say the SAME thing about that. The terminal already said
|
|
210
|
+
* `never opened`; the page rendered nothing, which an operator reads as a
|
|
211
|
+
* round that opened carrying no lens. That is a second answer to one question
|
|
212
|
+
* about one file, which is exactly what this module exists to prevent — so the
|
|
213
|
+
* words live here, once, and each surface only escapes and places them.
|
|
214
|
+
*/
|
|
215
|
+
export function roundLenses(entry: RoundEntry): string {
|
|
216
|
+
return entry.opened === undefined ? "never opened" : oneLineOf(payloadOf(entry.opened)["lenses"]);
|
|
217
|
+
}
|
|
218
|
+
|
|
112
219
|
/** One text row per round, each carrying the candidate it was bound to. */
|
|
113
220
|
export function roundRows(events: readonly RunEvent[]): readonly string[] {
|
|
114
221
|
return roundEntries(events).map((entry) => {
|
|
@@ -116,12 +223,180 @@ export function roundRows(events: readonly RunEvent[]): readonly string[] {
|
|
|
116
223
|
return [
|
|
117
224
|
` round ${entry.round}`,
|
|
118
225
|
`candidate ${entry.candidateTreeSha || "(none)"}`,
|
|
119
|
-
entry.opened === undefined ?
|
|
120
|
-
closed === undefined ? "open" : `${oneLineOf(closed["outcome"])} findings ${oneLineOf(closed["findings"])} cost ${
|
|
226
|
+
entry.opened === undefined ? roundLenses(entry) : `lenses ${roundLenses(entry)}`,
|
|
227
|
+
closed === undefined ? "open" : `${oneLineOf(closed["outcome"])} findings ${oneLineOf(closed["findings"])} cost ${costLabel(closed["cost"])}`,
|
|
121
228
|
].join(" ");
|
|
122
229
|
});
|
|
123
230
|
}
|
|
124
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
|
+
|
|
125
400
|
// ── The readout ──────────────────────────────────────────────────────────────
|
|
126
401
|
|
|
127
402
|
export interface Readout {
|
|
@@ -129,6 +404,12 @@ export interface Readout {
|
|
|
129
404
|
readonly present: readonly RunJournalRequiredEntry[];
|
|
130
405
|
readonly missing: readonly RunJournalRequiredEntry[];
|
|
131
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[];
|
|
132
413
|
/** The config-presence note, present on exactly the status that it explains. */
|
|
133
414
|
readonly note?: string;
|
|
134
415
|
}
|
|
@@ -146,17 +427,22 @@ export interface Readout {
|
|
|
146
427
|
* and a reader would eventually be told an entry is both present and missing —
|
|
147
428
|
* which is exactly what a rewritten copy of the pairing rule did.
|
|
148
429
|
*/
|
|
149
|
-
export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir
|
|
430
|
+
export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir?: string): Readout {
|
|
150
431
|
const present = RUN_JOURNAL_REQUIRED_ENTRIES.filter((entry) => runJournalCarries(events, entry));
|
|
151
432
|
const note =
|
|
152
|
-
evaluation.status === "complete-executor-only" && harnessConfigPresentAt(rootDir)
|
|
433
|
+
evaluation.status === "complete-executor-only" && rootDir !== undefined && harnessConfigPresentAt(rootDir)
|
|
153
434
|
? `no CLI gate completion in this journal; harness.config.ts present at ${oneLine(rootDir, 400)}`
|
|
154
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;
|
|
155
440
|
return {
|
|
156
441
|
status: evaluation.status,
|
|
157
442
|
present,
|
|
158
443
|
missing: evaluation.missing,
|
|
159
444
|
violations: evaluation.violations,
|
|
445
|
+
...(explanations === undefined ? {} : { explanations }),
|
|
160
446
|
...(note === undefined ? {} : { note }),
|
|
161
447
|
};
|
|
162
448
|
}
|
|
@@ -173,7 +459,13 @@ export function readoutRows(events: readonly RunEvent[], evaluation: RunJournalE
|
|
|
173
459
|
` present: ${readout.present.length === 0 ? "(none)" : readout.present.join(", ")}`,
|
|
174
460
|
` missing: ${readout.missing.length === 0 ? "(none)" : [...readout.missing].join(", ")}`,
|
|
175
461
|
];
|
|
176
|
-
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
|
+
}
|
|
177
469
|
if (readout.note !== undefined) rows.push(` note: ${readout.note}`);
|
|
178
470
|
return rows;
|
|
179
471
|
}
|
|
@@ -187,7 +479,12 @@ export interface WrittenOutcome {
|
|
|
187
479
|
}
|
|
188
480
|
|
|
189
481
|
export interface RunSummary {
|
|
190
|
-
/**
|
|
482
|
+
/**
|
|
483
|
+
* The run's PRIMARY ticket, by the family's own rule: the first the journal
|
|
484
|
+
* names. A run that read two — a dogfood item and the ordinary item it
|
|
485
|
+
* delivered — is named here by the first, and every entry that bound no
|
|
486
|
+
* ticket of its own belongs to it.
|
|
487
|
+
*/
|
|
191
488
|
readonly ticket: string;
|
|
192
489
|
/** A run with no `run.ended` is open, whatever else it holds. */
|
|
193
490
|
readonly open: boolean;
|
|
@@ -203,6 +500,13 @@ export interface RunSummary {
|
|
|
203
500
|
* is too.
|
|
204
501
|
*/
|
|
205
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;
|
|
206
510
|
readonly roundsOpened: number;
|
|
207
511
|
readonly roundsClosed: number;
|
|
208
512
|
readonly findings: { readonly P0: number; readonly P1: number; readonly P2: number; readonly P3: number };
|
|
@@ -218,8 +522,18 @@ export interface RunSummary {
|
|
|
218
522
|
readonly result?: string;
|
|
219
523
|
}
|
|
220
524
|
|
|
525
|
+
/**
|
|
526
|
+
* The CLI-written completion of one command that governs, which is the LAST of
|
|
527
|
+
* them — the same binding `evaluateRunJournal`'s own `cliCompletion` settled
|
|
528
|
+
* under V26-1709, for the same reason: a command is re-run to supersede its
|
|
529
|
+
* earlier outcome. The table and the completeness block sit on one row of one
|
|
530
|
+
* page, so a projection reading the first would report the outcome of a gate
|
|
531
|
+
* the delivery abandoned beside a verdict taken on the re-run —
|
|
532
|
+
* `run-01c68dea9d1d5fd0` in this repository's own store is a journal that gates
|
|
533
|
+
* twice and would read that way.
|
|
534
|
+
*/
|
|
221
535
|
const cliCompletionFor = (events: readonly RunEvent[], command: string): RunEvent | undefined =>
|
|
222
|
-
events.
|
|
536
|
+
events.findLast(
|
|
223
537
|
(event) => event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === command,
|
|
224
538
|
);
|
|
225
539
|
|
|
@@ -256,11 +570,12 @@ export function summarize(events: readonly RunEvent[]): RunSummary {
|
|
|
256
570
|
: undefined;
|
|
257
571
|
|
|
258
572
|
return {
|
|
259
|
-
ticket: oneLineOf(events
|
|
573
|
+
ticket: oneLineOf(runPrimaryTicket(events), 128),
|
|
260
574
|
open: ended === undefined,
|
|
261
575
|
startedAt,
|
|
262
576
|
lastAt,
|
|
263
577
|
durationSeconds: spanSeconds(startedAt, lastAt),
|
|
578
|
+
phases: projectRunPhases(events),
|
|
264
579
|
roundsOpened: events.filter((event) => event.kind === "review.round.opened").length,
|
|
265
580
|
roundsClosed: closed.length,
|
|
266
581
|
findings: {
|