@agent-delivery-harness/cli 0.1.0 → 0.5.0
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 +10 -5
- package/src/boundary.ts +302 -9
- 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 +419 -0
- package/src/commands/gate.ts +127 -18
- package/src/commands/maintain.ts +203 -0
- package/src/commands/managed.ts +478 -0
- package/src/commands/prepare.ts +123 -15
- package/src/commands/record.ts +100 -8
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +640 -0
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +173 -5
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +40 -4
- package/src/main.ts +38 -42
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +24 -0
- 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 +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +396 -0
- package/src/run-server.ts +905 -0
- package/src/run-surface.ts +334 -0
- 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
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One projection of a run journal, shared by every surface that renders it.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. `runs show` renders a journal to a terminal and
|
|
5
|
+
* `runs serve` renders the same journal to a page. Two renderers is fine; two
|
|
6
|
+
* ANSWERS is not. If each surface decided for itself which round a
|
|
7
|
+
* `review.round.closed` pairs with, what a `gate.reported` means next to a
|
|
8
|
+
* CLI-written `command.completed`, or which required entries a journal
|
|
9
|
+
* carries, an operator reading the page and the terminal would eventually be
|
|
10
|
+
* told two different things about one file on disk. So the semantics live here
|
|
11
|
+
* once — pairing, writer roles, gate and record outcomes, the readout rows —
|
|
12
|
+
* and the surfaces own only their own escaping and layout.
|
|
13
|
+
*
|
|
14
|
+
* WHAT STAYS WITH THE SURFACE. Neutralization. A terminal and a browser are
|
|
15
|
+
* hostile to different bytes: the terminal to escape sequences and newlines
|
|
16
|
+
* that forge a row, the browser to markup. `detailOf` collapses a value to one
|
|
17
|
+
* line's worth of neutralized text because BOTH surfaces want that; the page
|
|
18
|
+
* then escapes the result as markup and the terminal prints it. Nothing here
|
|
19
|
+
* emits markup, and nothing here decides a layout.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
RUN_JOURNAL_REQUIRED_ENTRIES,
|
|
23
|
+
runJournalCarries,
|
|
24
|
+
runPrimaryTicket,
|
|
25
|
+
projectRunActivities,
|
|
26
|
+
type RunEvent,
|
|
27
|
+
type RunJournalEvaluation,
|
|
28
|
+
type RunJournalRequiredEntry,
|
|
29
|
+
} from "@agent-delivery-harness/kernel";
|
|
30
|
+
import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
|
|
31
|
+
|
|
32
|
+
/** The three labels every readout carries, so no reader mistakes this for evidence. */
|
|
33
|
+
export const READOUT_LABELS = "self-attested; observability, not evidence; unbound to a record";
|
|
34
|
+
|
|
35
|
+
/** A single progress view shared by renderers; callers supply their observation clock. */
|
|
36
|
+
export function projectRunProgress(events: readonly RunEvent[], now: string, freshnessWindowMs?: number) {
|
|
37
|
+
const projection = projectRunActivities(events, { now, ...(freshnessWindowMs === undefined ? {} : { freshnessWindowMs }) });
|
|
38
|
+
const latestFindings = new Map<string, (typeof projection.findings)[number]>();
|
|
39
|
+
for (const finding of projection.findings) {
|
|
40
|
+
if (finding.current) latestFindings.set(String(finding.payload["findingId"]), finding);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
asOf: now,
|
|
44
|
+
...projection,
|
|
45
|
+
currentFindings: [...latestFindings.values()],
|
|
46
|
+
findingsCoverage: events.some(e => e.kind === "finding.observed") ? "reported" as const : "unreported" as const,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function costLabel(value: unknown): string {
|
|
51
|
+
const cost = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
|
52
|
+
if (cost["coverage"] === "unreported" || cost["total"] === undefined) return "unreported";
|
|
53
|
+
const measured = `${oneLineOf(cost["total"])} ${oneLineOf(cost["unit"])}`.trim();
|
|
54
|
+
return cost["coverage"] === "partial" ? `${measured} (partial coverage)` : measured;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Aggregate only comparable review counters; the run-wide counter overlaps them. */
|
|
58
|
+
export function projectCosts(events: readonly RunEvent[]) {
|
|
59
|
+
const allClosed = events.filter((event) => event.kind === "review.round.closed");
|
|
60
|
+
// v2 round costs are reported cumulative snapshots. Repeated/reopened round
|
|
61
|
+
// snapshots cannot be summed as independent consumption. Keep the latest
|
|
62
|
+
// comparable value and mark coverage partial when supersession occurred.
|
|
63
|
+
const snapshots = new Map<string, RunEvent>();
|
|
64
|
+
for (const event of allClosed) {
|
|
65
|
+
const payload = payloadOf(event);
|
|
66
|
+
const cost = payload["cost"] as Record<string, unknown>;
|
|
67
|
+
const key = event.version === "run-event/2"
|
|
68
|
+
? JSON.stringify([payload["roundId"], cost["reportedBy"], cost["unit"] ?? "unreported"])
|
|
69
|
+
: `legacy-${event.seq}`;
|
|
70
|
+
snapshots.set(key, event);
|
|
71
|
+
}
|
|
72
|
+
const closed = [...snapshots.values()];
|
|
73
|
+
const totals = new Map<string, { unit: string; reportedBy: string; total: number | null }>();
|
|
74
|
+
let unreportedEntries = 0;
|
|
75
|
+
let partial = allClosed.length !== closed.length || events.filter((event) => event.kind === "review.round.opened").length !== closed.length;
|
|
76
|
+
for (const event of closed) {
|
|
77
|
+
const cost = payloadOf(event)["cost"] as Record<string, unknown>;
|
|
78
|
+
if (cost["coverage"] === "unreported") {
|
|
79
|
+
unreportedEntries += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (cost["coverage"] === "partial") partial = true;
|
|
83
|
+
const unit = cost["unit"] as string;
|
|
84
|
+
const reportedBy = cost["reportedBy"] as string;
|
|
85
|
+
const key = JSON.stringify([reportedBy, unit]);
|
|
86
|
+
const prior = totals.get(key);
|
|
87
|
+
const sum = prior?.total === null ? Infinity : (prior?.total ?? 0) + (cost["total"] as number);
|
|
88
|
+
// The validated event keeps the original finite measurement even if a sum
|
|
89
|
+
// exceeds the numeric range. Null is unavailable, never measured zero.
|
|
90
|
+
if (!Number.isFinite(sum)) partial = true;
|
|
91
|
+
totals.set(key, { unit, total: Number.isFinite(sum) ? sum : null, reportedBy });
|
|
92
|
+
}
|
|
93
|
+
const ended = events.find((event) => event.kind === "run.ended");
|
|
94
|
+
return {
|
|
95
|
+
review: {
|
|
96
|
+
coverage: totals.size === 0 ? "unreported" : partial || unreportedEntries > 0 ? "partial" : "complete",
|
|
97
|
+
unreportedEntries,
|
|
98
|
+
totals: [...totals.values()],
|
|
99
|
+
},
|
|
100
|
+
run: ended === undefined ? { coverage: "unreported" } : payloadOf(ended)["cost"],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const payloadOf = (event: RunEvent): Record<string, unknown> =>
|
|
105
|
+
typeof event.payload === "object" && event.payload !== null ? (event.payload as Record<string, unknown>) : {};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The ticket an entry BOUND ITSELF to, as a row suffix, or nothing.
|
|
109
|
+
*
|
|
110
|
+
* Only the entry's own binding is rendered. An entry that omits `ticket` binds
|
|
111
|
+
* to the run's primary, which the runs table already names in the row above
|
|
112
|
+
* every timeline — repeating it on each unbound line would turn the one thing
|
|
113
|
+
* the member exists to say, that THIS entry belongs to THAT ticket, into
|
|
114
|
+
* boilerplate an operator learns to skip. The envelope is read rather than the
|
|
115
|
+
* payload because the validator holds the two to exact agreement and the
|
|
116
|
+
* envelope is what every other reader here reads.
|
|
117
|
+
*/
|
|
118
|
+
const boundTicket = (event: RunEvent): string => (event.ticket === undefined ? "" : ` for ${oneLineOf(event.ticket, 128)}`);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One row's detail for one event: enough to read the run without printing the
|
|
122
|
+
* whole payload back at the operator.
|
|
123
|
+
*/
|
|
124
|
+
export function detailOf(event: RunEvent): string {
|
|
125
|
+
const payload = payloadOf(event);
|
|
126
|
+
switch (event.kind) {
|
|
127
|
+
case "run.started":
|
|
128
|
+
return oneLineOf(payload["host"]) + (payload["displacedRunId"] === undefined ? "" : ` displaced ${oneLineOf(payload["displacedRunId"])}`);
|
|
129
|
+
case "run.ended":
|
|
130
|
+
return `${oneLineOf(payload["result"])} cost ${costLabel(payload["cost"])}`;
|
|
131
|
+
case "ticket.read":
|
|
132
|
+
return `${oneLineOf(payload["ticket"])} via ${oneLineOf(payload["tracker"])}`;
|
|
133
|
+
case "posture.declared":
|
|
134
|
+
return oneLineOf(payload["posture"]) + boundTicket(event);
|
|
135
|
+
case "lens.selected":
|
|
136
|
+
return `mandated ${oneLineOf(payload["mandated"])} selected ${oneLineOf(payload["selected"])} — ${oneLineOf(payload["rationale"])}`;
|
|
137
|
+
case "review.round.opened":
|
|
138
|
+
return `round ${oneLineOf(payload["round"])} on ${oneLineOf(event.candidateTreeSha)} lenses ${oneLineOf(payload["lenses"])}`;
|
|
139
|
+
case "review.round.closed":
|
|
140
|
+
return `round ${oneLineOf(payload["round"])} ${oneLineOf(payload["outcome"])} findings ${oneLineOf(payload["findings"])}`;
|
|
141
|
+
case "command.completed":
|
|
142
|
+
return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
|
|
143
|
+
case "gate.reported":
|
|
144
|
+
return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms${boundTicket(event)}`;
|
|
145
|
+
case "pr.opened":
|
|
146
|
+
return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}${boundTicket(event)}`;
|
|
147
|
+
case "blocker.recorded":
|
|
148
|
+
return `${oneLineOf(payload["code"])} — ${oneLineOf(payload["summary"])}`;
|
|
149
|
+
case "decision.recorded":
|
|
150
|
+
return `${oneLineOf(payload["fork"])} — ${oneLineOf(payload["choice"])}${payload["cited"] === undefined ? "" : ` (cited ${oneLineOf(payload["cited"])})`}`;
|
|
151
|
+
case "compounding.recorded":
|
|
152
|
+
return `${oneLineOf(payload["outcome"])}${payload["reference"] === undefined ? "" : ` — ${oneLineOf(payload["reference"])}`}`;
|
|
153
|
+
case "context.saved":
|
|
154
|
+
return `stage ${oneLineOf(payload["stage"])} (observation only)`;
|
|
155
|
+
case "action.intent":
|
|
156
|
+
case "action.observed":
|
|
157
|
+
return `${oneLineOf(payload["actionId"])} ${oneLineOf(payload["outcome"] ?? "unknown")} reference ${oneLineOf(payload["reference"])}`;
|
|
158
|
+
default:
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Rounds ───────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export interface RoundEntry {
|
|
166
|
+
/** The round label as the executor wrote it, reduced to one line. */
|
|
167
|
+
readonly round: string;
|
|
168
|
+
/**
|
|
169
|
+
* The candidate this round was bound to, taken from the ENVELOPE of the
|
|
170
|
+
* opening event (or the closing one when a round was never opened). The
|
|
171
|
+
* envelope is what the store holds to agreement with the payload, so it is
|
|
172
|
+
* the member every reader reads.
|
|
173
|
+
*/
|
|
174
|
+
readonly candidateTreeSha: string;
|
|
175
|
+
readonly opened?: RunEvent;
|
|
176
|
+
readonly closed?: RunEvent;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One entry per round, in the order the journal first mentions it. */
|
|
180
|
+
export function roundEntries(events: readonly RunEvent[]): readonly RoundEntry[] {
|
|
181
|
+
const rounds = new Map<string, { opened?: RunEvent; closed?: RunEvent }>();
|
|
182
|
+
for (const event of events) {
|
|
183
|
+
if (event.kind !== "review.round.opened" && event.kind !== "review.round.closed") continue;
|
|
184
|
+
const key = oneLineOf(payloadOf(event)["round"], 32);
|
|
185
|
+
const entry = rounds.get(key) ?? {};
|
|
186
|
+
if (event.kind === "review.round.opened") entry.opened = event;
|
|
187
|
+
else entry.closed = event;
|
|
188
|
+
rounds.set(key, entry);
|
|
189
|
+
}
|
|
190
|
+
return [...rounds.entries()].map(([round, entry]) => {
|
|
191
|
+
const anchor = entry.opened ?? entry.closed;
|
|
192
|
+
return {
|
|
193
|
+
round,
|
|
194
|
+
candidateTreeSha: oneLineOf(anchor?.candidateTreeSha),
|
|
195
|
+
...(entry.opened === undefined ? {} : { opened: entry.opened }),
|
|
196
|
+
...(entry.closed === undefined ? {} : { closed: entry.closed }),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The lenses a round carried, or the fact that it never opened.
|
|
203
|
+
*
|
|
204
|
+
* A round with no `review.round.opened` has no lens set to name, and the two
|
|
205
|
+
* surfaces have to say the SAME thing about that. The terminal already said
|
|
206
|
+
* `never opened`; the page rendered nothing, which an operator reads as a
|
|
207
|
+
* round that opened carrying no lens. That is a second answer to one question
|
|
208
|
+
* about one file, which is exactly what this module exists to prevent — so the
|
|
209
|
+
* words live here, once, and each surface only escapes and places them.
|
|
210
|
+
*/
|
|
211
|
+
export function roundLenses(entry: RoundEntry): string {
|
|
212
|
+
return entry.opened === undefined ? "never opened" : oneLineOf(payloadOf(entry.opened)["lenses"]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One text row per round, each carrying the candidate it was bound to. */
|
|
216
|
+
export function roundRows(events: readonly RunEvent[]): readonly string[] {
|
|
217
|
+
return roundEntries(events).map((entry) => {
|
|
218
|
+
const closed = entry.closed === undefined ? undefined : payloadOf(entry.closed);
|
|
219
|
+
return [
|
|
220
|
+
` round ${entry.round}`,
|
|
221
|
+
`candidate ${entry.candidateTreeSha || "(none)"}`,
|
|
222
|
+
entry.opened === undefined ? roundLenses(entry) : `lenses ${roundLenses(entry)}`,
|
|
223
|
+
closed === undefined ? "open" : `${oneLineOf(closed["outcome"])} findings ${oneLineOf(closed["findings"])} cost ${costLabel(closed["cost"])}`,
|
|
224
|
+
].join(" ");
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── The readout ──────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export interface Readout {
|
|
231
|
+
readonly status: RunJournalEvaluation["status"];
|
|
232
|
+
readonly present: readonly RunJournalRequiredEntry[];
|
|
233
|
+
readonly missing: readonly RunJournalRequiredEntry[];
|
|
234
|
+
readonly violations: readonly string[];
|
|
235
|
+
/** The config-presence note, present on exactly the status that it explains. */
|
|
236
|
+
readonly note?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What a journal carries and what it lacks.
|
|
241
|
+
*
|
|
242
|
+
* Present is read off the journal, not inferred by subtracting `missing` from
|
|
243
|
+
* the required list. The two are not complements: `gate.reported` is required
|
|
244
|
+
* only of an executor-only journal, so a journal that never carried one would
|
|
245
|
+
* otherwise be reported as HAVING it.
|
|
246
|
+
*
|
|
247
|
+
* The predicate is the evaluator's own, imported rather than restated. A
|
|
248
|
+
* second implementation here would be a second answer to the same question,
|
|
249
|
+
* and a reader would eventually be told an entry is both present and missing —
|
|
250
|
+
* which is exactly what a rewritten copy of the pairing rule did.
|
|
251
|
+
*/
|
|
252
|
+
export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir?: string): Readout {
|
|
253
|
+
const present = RUN_JOURNAL_REQUIRED_ENTRIES.filter((entry) => runJournalCarries(events, entry));
|
|
254
|
+
const note =
|
|
255
|
+
evaluation.status === "complete-executor-only" && rootDir !== undefined && harnessConfigPresentAt(rootDir)
|
|
256
|
+
? `no CLI gate completion in this journal; harness.config.ts present at ${oneLine(rootDir, 400)}`
|
|
257
|
+
: undefined;
|
|
258
|
+
return {
|
|
259
|
+
status: evaluation.status,
|
|
260
|
+
present,
|
|
261
|
+
missing: evaluation.missing,
|
|
262
|
+
violations: evaluation.violations,
|
|
263
|
+
...(note === undefined ? {} : { note }),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The readout as text rows. Labeled three ways, listing what the journal has
|
|
269
|
+
* and what it lacks under the ordered rule, with the config-presence note
|
|
270
|
+
* attached to the one status it explains.
|
|
271
|
+
*/
|
|
272
|
+
export function readoutRows(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir: string): readonly string[] {
|
|
273
|
+
const readout = readoutOf(events, evaluation, rootDir);
|
|
274
|
+
const rows = [
|
|
275
|
+
` completeness: ${readout.status} (${READOUT_LABELS})`,
|
|
276
|
+
` present: ${readout.present.length === 0 ? "(none)" : readout.present.join(", ")}`,
|
|
277
|
+
` missing: ${readout.missing.length === 0 ? "(none)" : [...readout.missing].join(", ")}`,
|
|
278
|
+
];
|
|
279
|
+
if (readout.violations.length > 0) rows.push(` violations: ${[...readout.violations].join(", ")}`);
|
|
280
|
+
if (readout.note !== undefined) rows.push(` note: ${readout.note}`);
|
|
281
|
+
return rows;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── The run's headline ───────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/** One command outcome and the role of whoever wrote it down. */
|
|
287
|
+
export interface WrittenOutcome {
|
|
288
|
+
readonly outcome: string;
|
|
289
|
+
readonly writer: "cli" | "executor";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface RunSummary {
|
|
293
|
+
/**
|
|
294
|
+
* The run's PRIMARY ticket, by the family's own rule: the first the journal
|
|
295
|
+
* names. A run that read two — a dogfood item and the ordinary item it
|
|
296
|
+
* delivered — is named here by the first, and every entry that bound no
|
|
297
|
+
* ticket of its own belongs to it.
|
|
298
|
+
*/
|
|
299
|
+
readonly ticket: string;
|
|
300
|
+
/** A run with no `run.ended` is open, whatever else it holds. */
|
|
301
|
+
readonly open: boolean;
|
|
302
|
+
readonly startedAt: string;
|
|
303
|
+
readonly lastAt: string;
|
|
304
|
+
/**
|
|
305
|
+
* The journal's own span, first event to last, in whole seconds.
|
|
306
|
+
*
|
|
307
|
+
* NOT "now minus the start". The viewer renders a file, and a file's span is
|
|
308
|
+
* a property of the file: two operators refreshing the page a minute apart
|
|
309
|
+
* must be told the same thing about the same run, and a run whose journal
|
|
310
|
+
* ends is not still accruing duration. `at` is second-granularity, so this
|
|
311
|
+
* is too.
|
|
312
|
+
*/
|
|
313
|
+
readonly durationSeconds: number;
|
|
314
|
+
readonly roundsOpened: number;
|
|
315
|
+
readonly roundsClosed: number;
|
|
316
|
+
readonly findings: { readonly P0: number; readonly P1: number; readonly P2: number; readonly P3: number };
|
|
317
|
+
/**
|
|
318
|
+
* The gate outcome and who claimed it: the CLI-written `command.completed`
|
|
319
|
+
* for `gate` when there is one, the executor's `gate.reported` otherwise.
|
|
320
|
+
* The writer is the point of the label — an adopter whose gate is not a
|
|
321
|
+
* product command has only the executor's word for it.
|
|
322
|
+
*/
|
|
323
|
+
readonly gate?: WrittenOutcome;
|
|
324
|
+
readonly record?: WrittenOutcome;
|
|
325
|
+
/** `run.ended`'s result, when the run has ended. */
|
|
326
|
+
readonly result?: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The CLI-written completion of one command that governs, which is the LAST of
|
|
331
|
+
* them — the same binding `evaluateRunJournal`'s own `cliCompletion` settled
|
|
332
|
+
* under V26-1709, for the same reason: a command is re-run to supersede its
|
|
333
|
+
* earlier outcome. The table and the completeness block sit on one row of one
|
|
334
|
+
* page, so a projection reading the first would report the outcome of a gate
|
|
335
|
+
* the delivery abandoned beside a verdict taken on the re-run —
|
|
336
|
+
* `run-01c68dea9d1d5fd0` in this repository's own store is a journal that gates
|
|
337
|
+
* twice and would read that way.
|
|
338
|
+
*/
|
|
339
|
+
const cliCompletionFor = (events: readonly RunEvent[], command: string): RunEvent | undefined =>
|
|
340
|
+
events.findLast(
|
|
341
|
+
(event) => event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === command,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const severityOf = (value: unknown, key: string): number => {
|
|
345
|
+
const findings = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
346
|
+
const count = findings[key];
|
|
347
|
+
return typeof count === "number" && Number.isFinite(count) ? count : 0;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Seconds between two contract instants, or zero when either cannot be read. */
|
|
351
|
+
function spanSeconds(from: string, to: string): number {
|
|
352
|
+
const start = Date.parse(from);
|
|
353
|
+
const end = Date.parse(to);
|
|
354
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
|
|
355
|
+
return Math.round((end - start) / 1000);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function summarize(events: readonly RunEvent[]): RunSummary {
|
|
359
|
+
const first = events[0];
|
|
360
|
+
const last = events[events.length - 1];
|
|
361
|
+
const startedAt = first?.at ?? "";
|
|
362
|
+
const lastAt = last?.at ?? "";
|
|
363
|
+
const ended = events.find((event) => event.kind === "run.ended");
|
|
364
|
+
const gateCompletion = cliCompletionFor(events, "gate");
|
|
365
|
+
const gateReported = events.find((event) => event.kind === "gate.reported");
|
|
366
|
+
const recordCompletion = cliCompletionFor(events, "record");
|
|
367
|
+
const closed = events.filter((event) => event.kind === "review.round.closed");
|
|
368
|
+
|
|
369
|
+
const gate =
|
|
370
|
+
gateCompletion !== undefined
|
|
371
|
+
? { outcome: oneLineOf(payloadOf(gateCompletion)["outcome"], 64), writer: "cli" as const }
|
|
372
|
+
: gateReported !== undefined
|
|
373
|
+
? { outcome: oneLineOf(payloadOf(gateReported)["outcome"], 64), writer: "executor" as const }
|
|
374
|
+
: undefined;
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
ticket: oneLineOf(runPrimaryTicket(events), 128),
|
|
378
|
+
open: ended === undefined,
|
|
379
|
+
startedAt,
|
|
380
|
+
lastAt,
|
|
381
|
+
durationSeconds: spanSeconds(startedAt, lastAt),
|
|
382
|
+
roundsOpened: events.filter((event) => event.kind === "review.round.opened").length,
|
|
383
|
+
roundsClosed: closed.length,
|
|
384
|
+
findings: {
|
|
385
|
+
P0: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P0"), 0),
|
|
386
|
+
P1: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P1"), 0),
|
|
387
|
+
P2: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P2"), 0),
|
|
388
|
+
P3: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P3"), 0),
|
|
389
|
+
},
|
|
390
|
+
...(gate === undefined ? {} : { gate }),
|
|
391
|
+
...(recordCompletion === undefined
|
|
392
|
+
? {}
|
|
393
|
+
: { record: { outcome: oneLineOf(payloadOf(recordCompletion)["outcome"], 64), writer: "cli" as const } }),
|
|
394
|
+
...(ended === undefined ? {} : { result: oneLineOf(payloadOf(ended)["result"], 64) }),
|
|
395
|
+
};
|
|
396
|
+
}
|