run402 4.4.0 → 4.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/cli.mjs CHANGED
@@ -41,6 +41,7 @@ Commands:
41
41
  org Org membership, invites & audit (whoami, list, member, invite, audit)
42
42
  grants Per-project capability grants for agent/CI principals (create, revoke)
43
43
  events What happened to your project since you last looked (cursored feed)
44
+ errors Grouped error fingerprints + a promote/revert verdict (release-baselined)
44
45
  jobs Submit and inspect platform-managed jobs
45
46
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
46
47
  secrets Manage project secrets (set, list, delete)
@@ -247,6 +248,11 @@ switch (cmd) {
247
248
  await run(sub, rest);
248
249
  break;
249
250
  }
251
+ case "errors": {
252
+ const { run } = await import("./lib/errors.mjs");
253
+ await run(sub, rest);
254
+ break;
255
+ }
250
256
  case "jobs": {
251
257
  const { run } = await import("./lib/jobs.mjs");
252
258
  await run(sub, rest);
package/lib/errors.mjs ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * run402 errors — grouped error fingerprints + a release-baselined
3
+ * promote/revert verdict (gateway release-error-rollup).
4
+ *
5
+ * Two audiences from one wire envelope:
6
+ * --json → the gateway envelope VERBATIM (list page, detail row, or the
7
+ * watch triggering/final page). No reshaping — CLI-JSON and HTTP
8
+ * consumers see one contract.
9
+ * default → a rendered read: the verdict first (so "0 errors over 0 traffic"
10
+ * is never mistaken for health), then one line per fingerprint,
11
+ * then a runnable logs drill-down.
12
+ *
13
+ * The promote gate: `--new-in <release> --fail-on-new` exits 0 when no error
14
+ * identity was first seen under that release, 1 when new fingerprints appear,
15
+ * and 2 when a verdict could not be produced (outage / auth / gate misuse) —
16
+ * so a script can never mistake an outage for a clean verdict. `--watch` tails
17
+ * the release under real traffic and fails fast the moment a new identity lands.
18
+ */
19
+
20
+ import { resolveProjectId } from "./config.mjs";
21
+ import { getSdk } from "./sdk.mjs";
22
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
23
+ import {
24
+ assertKnownFlags,
25
+ assertAllowedValue,
26
+ flagValue,
27
+ normalizeArgv,
28
+ parseIntegerFlag,
29
+ positionalArgs,
30
+ } from "./argparse.mjs";
31
+
32
+ /** Wire `kind` vocabulary (mirrors the gateway's KINDS set). */
33
+ export const KINDS = ["uncaught", "boot_crash", "invoke_failed", "handled_5xx"];
34
+
35
+ /** Poll cadence floor for --watch, enforced client-side. */
36
+ export const INTERVAL_FLOOR_MS = 5000;
37
+ /** Default poll cadence for --watch. */
38
+ export const DEFAULT_INTERVAL_MS = 15000;
39
+
40
+ const HELP = `run402 errors — grouped error fingerprints + a promote/revert verdict
41
+
42
+ Usage:
43
+ run402 errors [--project <id>] [filters] [--json]
44
+ run402 errors <fingerprint_id> [--project <id>] [--json]
45
+ run402 errors --new-in <release_id|active> --fail-on-new [--json]
46
+ run402 errors --new-in <release_id|active> --watch <dur> [--fail-on-new]
47
+
48
+ What this is:
49
+ A "fingerprint" is one error IDENTITY — errors with the same normalized
50
+ message + stable stack frames collapse into a single group with a count,
51
+ a first/last-seen, and the releases they were seen under. You read groups,
52
+ not a firehose of individual lines.
53
+
54
+ The "verdict" pairs new-vs-recurring identity counts with the invocations
55
+ in the window and a coverage note. That pairing is the point: 0 errors over
56
+ 0 traffic is ABSENCE OF SIGNAL, not proven health — the verdict makes the
57
+ two distinguishable so an empty result is never silently read as "healthy".
58
+
59
+ The "baseline" is the previously ACTIVE release, resolved by activation
60
+ history (not lineage) — so it is rollback-safe: after A -> B -> rollback to
61
+ A -> C, C's baseline is A, and identities first seen under B are not
62
+ attributed to C. "--new-in <release>" selects the identities first seen
63
+ under that release; "active" resolves the project's live release.
64
+
65
+ Filters (each maps 1:1 to a query param):
66
+ --project <id> Project to read (defaults to the active project)
67
+ --since <iso> Window start (ISO-8601). Default: 24h before --until
68
+ --until <iso> Window end (ISO-8601). Default: now
69
+ --function <name> Only this function's fingerprints
70
+ --kind <kind> One of: ${KINDS.join(", ")}
71
+ --fingerprint <id> Only this fingerprint id (exact)
72
+ --new-in <rel|active> Only identities first seen under this release
73
+ (a release id, or the literal "active" for live)
74
+ --limit <n> Page size (default 50, max 200)
75
+ --cursor <cursor> Opaque cursor from a prior response's next_cursor.
76
+ Never parse or compare it — pass it back as-is.
77
+
78
+ Output:
79
+ --json Emit the gateway envelope verbatim (never reshaped)
80
+ --watch <dur> Poll the release for new identities for <dur>, then
81
+ stop. Requires --new-in. Durations: 90s, 10m, 2h, or a
82
+ bare number of seconds. Progress ticks go to stderr so
83
+ stdout stays pipeable.
84
+ --interval <dur> Poll cadence for --watch (default 15s, floor 5s)
85
+ --fail-on-new Turn the run into the promote gate (exit codes below).
86
+ Requires --new-in.
87
+
88
+ Quality tiers (fingerprint_quality):
89
+ frame_names full fidelity — grouped by stable stack frames
90
+ message_only medium — grouped by normalized message
91
+ coarse the function predates the error side-channel; redeploy it and
92
+ future occurrences fingerprint at full fidelity. The verdict's
93
+ coverage line counts how many functions are still coarse.
94
+
95
+ Exit codes (the promote gate — only when --fail-on-new is set):
96
+ 0 clean — no identity was first seen under the --new-in release
97
+ 1 new — new identities appeared (printed with a sample id + a runnable
98
+ logs command for each, so you can act without another query).
99
+ Under --watch this fails FAST the instant a new identity lands.
100
+ 2 unknown — a verdict could NOT be produced: network / auth / API failure,
101
+ or gate misuse (--fail-on-new without --new-in). A script must
102
+ never read an outage as a clean verdict, so this is distinct
103
+ from 1. Without --fail-on-new, failures are the usual exit 1.
104
+
105
+ Auth:
106
+ The addressed project's own anon_key or service_key. A key for project A
107
+ requesting project B's errors gets 403 (never a 404 that leaks existence).
108
+ Read-only; never lifecycle-gated.
109
+
110
+ The golden path — gate a promote:
111
+ run402 deploy promote --project <id> --release <rel>
112
+ run402 errors --project <id> --new-in <rel> --watch 10m --fail-on-new
113
+ # exit 0 -> the new release is clean; exit 1 -> revert, drill in via logs.
114
+ # (a promote response already hands you this exact command in next_actions
115
+ # as the "watch_errors" action — copy it verbatim.)
116
+
117
+ Examples:
118
+ run402 errors # last 24h, verdict + groups
119
+ run402 errors --function checkout --kind uncaught
120
+ run402 errors --since 2026-07-11T00:00:00Z --limit 200
121
+ run402 errors fp_9b21fa # one fingerprint, all samples
122
+ run402 errors --new-in active # what's new under the live release
123
+ run402 errors --new-in rel_01JX --fail-on-new # one-shot gate (CI)
124
+ run402 errors --new-in rel_01JX --watch 10m --interval 30s --fail-on-new
125
+ `;
126
+
127
+ export async function run(sub, args = []) {
128
+ const argv = [sub, ...(Array.isArray(args) ? args : [])].filter((x) => x !== undefined && x !== null);
129
+ if (argv.includes("--help") || argv.includes("-h")) {
130
+ console.log(HELP);
131
+ process.exit(0);
132
+ }
133
+
134
+ const a = normalizeArgv(argv);
135
+ const valueFlags = [
136
+ "--project", "--since", "--until", "--function", "--kind",
137
+ "--fingerprint", "--new-in", "--limit", "--cursor", "--watch", "--interval",
138
+ ];
139
+ const boolFlags = ["--json", "--fail-on-new", "--help", "-h"];
140
+ assertKnownFlags(a, [...valueFlags, ...boolFlags], valueFlags);
141
+
142
+ const positionals = positionalArgs(a, valueFlags);
143
+ if (positionals.length > 1) {
144
+ fail({
145
+ code: "BAD_USAGE",
146
+ message: `Unexpected extra argument: ${positionals[1]}`,
147
+ hint: "Pass at most one <fingerprint_id> for the detail view. Run `run402 errors --help`.",
148
+ });
149
+ }
150
+ const fingerprintId = positionals[0] ?? null;
151
+ const json = a.includes("--json");
152
+ const failOnNew = a.includes("--fail-on-new");
153
+ const project = flagValue(a, "--project");
154
+ const newIn = flagValue(a, "--new-in");
155
+ const watchRaw = flagValue(a, "--watch");
156
+ const intervalRaw = flagValue(a, "--interval");
157
+
158
+ // ---- Detail view (single positional fingerprint id) ----------------------
159
+ if (fingerprintId) {
160
+ const listOnly = [
161
+ "--since", "--until", "--function", "--kind", "--fingerprint",
162
+ "--new-in", "--limit", "--cursor", "--watch", "--interval", "--fail-on-new",
163
+ ];
164
+ const offending = listOnly.find((f) => a.includes(f));
165
+ if (offending) {
166
+ fail({
167
+ code: "BAD_USAGE",
168
+ message: `${offending} is not valid with a <fingerprint_id> (detail view).`,
169
+ hint: "Detail view accepts only --project and --json. Drop the fingerprint id to list + get a verdict.",
170
+ });
171
+ }
172
+ const projectId = resolveProjectId(project);
173
+ let detail;
174
+ try {
175
+ detail = await getSdk().errors.get(projectId, fingerprintId);
176
+ } catch (err) {
177
+ reportSdkError(err);
178
+ return;
179
+ }
180
+ if (json) {
181
+ console.log(JSON.stringify(detail, null, 2));
182
+ return;
183
+ }
184
+ console.log(renderHumanDetail(detail));
185
+ return;
186
+ }
187
+
188
+ // ---- Gate misuse (order matters for the exit-code contract) --------------
189
+ // --fail-on-new without --new-in is a verdict that can't be produced -> 2.
190
+ if (failOnNew && !newIn) {
191
+ fail({
192
+ code: "BAD_USAGE",
193
+ message: "--fail-on-new requires --new-in <release_id|active>.",
194
+ hint: "The promote gate compares identities first seen under a release against its baseline. Pass --new-in <release_id> (or `active`).",
195
+ exit_code: 2,
196
+ });
197
+ }
198
+ // --watch without --new-in (and without the gate) is ordinary bad usage -> 1.
199
+ if (watchRaw != null && !newIn) {
200
+ fail({
201
+ code: "BAD_USAGE",
202
+ message: "--watch requires --new-in <release_id|active>.",
203
+ hint: "Watch tails a specific release for new error identities. Pass --new-in <release_id> (or `active`).",
204
+ });
205
+ }
206
+
207
+ // ---- Client-side validation (fail fast, before project / network) --------
208
+ // Build list opts (1:1 with query params) and validate every flag value up
209
+ // front so a malformed flag never surfaces as a project-resolution error.
210
+ const opts = {};
211
+ const since = flagValue(a, "--since");
212
+ const until = flagValue(a, "--until");
213
+ const fn = flagValue(a, "--function");
214
+ const kind = flagValue(a, "--kind");
215
+ const fingerprint = flagValue(a, "--fingerprint");
216
+ const limit = flagValue(a, "--limit");
217
+ const cursor = flagValue(a, "--cursor");
218
+ if (since != null) opts.since = since;
219
+ if (until != null) opts.until = until;
220
+ if (fn != null) opts.function = fn;
221
+ if (kind != null) {
222
+ assertAllowedValue(kind, KINDS, "--kind");
223
+ opts.kind = kind;
224
+ }
225
+ if (fingerprint != null) opts.fingerprint = fingerprint;
226
+ if (newIn != null) opts.newIn = newIn;
227
+ if (limit != null) opts.limit = parseIntegerFlag("--limit", limit, { min: 1, max: 200 });
228
+ if (cursor != null) opts.cursor = cursor;
229
+
230
+ let watchConfig = null;
231
+ if (watchRaw != null) {
232
+ const durationMs = parseDurationMs(watchRaw);
233
+ if (durationMs == null || durationMs <= 0) {
234
+ fail({
235
+ code: "BAD_FLAG",
236
+ message: `--watch must be a duration like 90s, 10m, 2h, or a bare number of seconds (got: ${watchRaw})`,
237
+ details: { flag: "--watch", value: watchRaw },
238
+ });
239
+ }
240
+ let intervalMs = DEFAULT_INTERVAL_MS;
241
+ if (intervalRaw != null) {
242
+ const parsed = parseDurationMs(intervalRaw);
243
+ if (parsed == null || parsed <= 0) {
244
+ fail({
245
+ code: "BAD_FLAG",
246
+ message: `--interval must be a duration like 15s, 1m, or a bare number of seconds (got: ${intervalRaw})`,
247
+ details: { flag: "--interval", value: intervalRaw },
248
+ });
249
+ }
250
+ intervalMs = parsed;
251
+ }
252
+ if (intervalMs < INTERVAL_FLOOR_MS) {
253
+ process.stderr.write(`(interval ${fmtDuration(intervalMs)} is below the ${fmtDuration(INTERVAL_FLOOR_MS)} floor; using ${fmtDuration(INTERVAL_FLOOR_MS)})\n`);
254
+ intervalMs = INTERVAL_FLOOR_MS;
255
+ }
256
+ watchConfig = { durationMs, intervalMs };
257
+ }
258
+
259
+ // ---- Project resolution (prerequisite for any call) ----------------------
260
+ const projectId = resolveProjectId(project);
261
+
262
+ // ---- Watch mode ----------------------------------------------------------
263
+ if (watchConfig != null) {
264
+ await runWatch({ projectId, newIn, ...watchConfig, failOnNew, json });
265
+ return;
266
+ }
267
+
268
+ // ---- Single-shot list ----------------------------------------------------
269
+ let page;
270
+ try {
271
+ page = await getSdk().errors.list(projectId, opts);
272
+ } catch (err) {
273
+ // Under the gate, an error means the verdict is UNKNOWN (exit 2), never a
274
+ // clean/dirty verdict.
275
+ if (failOnNew) failVerdictUnavailable(err);
276
+ reportSdkError(err);
277
+ return;
278
+ }
279
+
280
+ if (json) console.log(JSON.stringify(page, null, 2));
281
+
282
+ if (failOnNew) {
283
+ const totalNew = Number(page?.verdict?.new_fingerprints ?? 0);
284
+ if (totalNew > 0) {
285
+ if (!json) console.log(renderFailOnNewList(page?.errors ?? [], newIn, totalNew));
286
+ process.exit(1);
287
+ }
288
+ if (!json) console.log(renderCleanGate(page?.verdict, newIn));
289
+ process.exit(0);
290
+ }
291
+
292
+ if (!json) console.log(renderHumanList(page));
293
+ }
294
+
295
+ // ─── Watch driver ────────────────────────────────────────────────────────────
296
+
297
+ async function runWatch({ projectId, newIn, durationMs, intervalMs, failOnNew, json }) {
298
+ let lastPage = null;
299
+ let triggeringPage = null;
300
+
301
+ const onPoll = (page, meta) => {
302
+ if (page && typeof page === "object") lastPage = page;
303
+ const newSoFar = Number(page?.verdict?.new_fingerprints ?? 0);
304
+ if (failOnNew && newSoFar > 0 && !triggeringPage) triggeringPage = page;
305
+ const poll = meta?.poll ?? "?";
306
+ const elapsed = fmtDuration(meta?.elapsedMs ?? 0);
307
+ // Progress lives on stderr so stdout carries only the final page / render.
308
+ process.stderr.write(`watch · poll ${poll} · ${elapsed} elapsed · ${fmtInt(newSoFar)} new fingerprint(s) so far\n`);
309
+ };
310
+
311
+ let result;
312
+ try {
313
+ result = await getSdk().errors.watch(projectId, {
314
+ newIn,
315
+ durationMs,
316
+ intervalMs,
317
+ onPoll,
318
+ failFast: failOnNew,
319
+ });
320
+ } catch (err) {
321
+ if (failOnNew) failVerdictUnavailable(err);
322
+ reportSdkError(err);
323
+ return;
324
+ }
325
+
326
+ const clean = result?.clean === true;
327
+ const newErrors = Array.isArray(result?.new_errors) ? result.new_errors : [];
328
+ const totalNew = Number(result?.verdict?.new_fingerprints ?? newErrors.length);
329
+
330
+ if (json) {
331
+ // The triggering page if we fired early, else the last poll's page. Fall
332
+ // back to a page-shaped envelope only if no poll ever ran (edge case).
333
+ const page = triggeringPage ?? lastPage ?? {
334
+ verdict: result?.verdict ?? null,
335
+ errors: newErrors,
336
+ has_more: false,
337
+ };
338
+ console.log(JSON.stringify(page, null, 2));
339
+ }
340
+
341
+ if (failOnNew) {
342
+ if (!clean) {
343
+ if (!json) console.log(renderFailOnNewList(newErrors, newIn, totalNew));
344
+ process.exit(1);
345
+ }
346
+ if (!json) {
347
+ console.log(renderCleanGate(result?.verdict, newIn, { watched: true, durationMs, polls: result?.polls }));
348
+ }
349
+ process.exit(0);
350
+ }
351
+
352
+ // --watch without --fail-on-new: report and exit 0.
353
+ if (!json) {
354
+ const page = triggeringPage ?? lastPage;
355
+ if (page) console.log(renderHumanList(page));
356
+ else console.log(renderVerdict(result?.verdict));
357
+ }
358
+ }
359
+
360
+ // ─── Gate-failure emitter (exit 2) ───────────────────────────────────────────
361
+
362
+ /**
363
+ * A verdict could not be produced. Distinct from exit 1 (new fingerprints) so
364
+ * a script never reads an outage as a clean/dirty verdict. Uses fail() with
365
+ * exit_code 2 while preserving the underlying error's http/code for diagnosis.
366
+ */
367
+ function failVerdictUnavailable(err) {
368
+ const http = err?.status ?? null;
369
+ const underlying = err?.code ?? (err?.body && typeof err.body === "object" ? err.body.code : undefined) ?? null;
370
+ const detail =
371
+ (err?.body && typeof err.body === "object" ? err.body.message : undefined) ??
372
+ err?.message ??
373
+ String(err);
374
+ fail({
375
+ code: "VERDICT_UNAVAILABLE",
376
+ message: `Could not produce an error verdict: ${detail}`,
377
+ hint: "Network / auth / API failure means the gate result is UNKNOWN (exit 2) — not a clean verdict (exit 0) and not new-fingerprints (exit 1). Retry, or run `run402 doctor`.",
378
+ details: { http, underlying_code: underlying },
379
+ retryable: true,
380
+ exit_code: 2,
381
+ });
382
+ }
383
+
384
+ // ─── Pure helpers (exported for unit tests; no network, no SDK) ──────────────
385
+
386
+ /**
387
+ * Parse a watch/interval duration into milliseconds.
388
+ * Accepts `90s`, `10m`, `2h`, or a bare number of seconds (`600`). A bare
389
+ * number is seconds. Returns null for anything malformed (caller fails).
390
+ */
391
+ export function parseDurationMs(raw) {
392
+ if (raw == null) return null;
393
+ const s = String(raw).trim().toLowerCase();
394
+ const m = /^(\d+)(s|m|h)?$/.exec(s);
395
+ if (!m) return null;
396
+ const n = Number.parseInt(m[1], 10);
397
+ if (!Number.isFinite(n)) return null;
398
+ const unit = m[2] || "s";
399
+ const mult = unit === "h" ? 3600000 : unit === "m" ? 60000 : 1000;
400
+ return n * mult;
401
+ }
402
+
403
+ /** Compact, human-friendly duration for a millisecond span (e.g. 90000 -> "90s"). */
404
+ export function fmtDuration(ms) {
405
+ const total = Math.max(0, Math.round(Number(ms) / 1000));
406
+ if (total === 0) return "0s";
407
+ if (total % 86400 === 0) return `${total / 86400}d`;
408
+ if (total % 3600 === 0) return `${total / 3600}h`;
409
+ if (total % 60 === 0) return `${total / 60}m`;
410
+ return `${total}s`;
411
+ }
412
+
413
+ /** Thousands-separated integer. */
414
+ export function fmtInt(n) {
415
+ const v = Number(n);
416
+ if (!Number.isFinite(v)) return String(n ?? 0);
417
+ return v.toLocaleString("en-US");
418
+ }
419
+
420
+ /** Truncate a display string to ~n chars, appending an ellipsis. */
421
+ export function truncate(s, n = 100) {
422
+ const str = String(s ?? "");
423
+ return str.length <= n ? str : `${str.slice(0, Math.max(0, n - 1))}…`;
424
+ }
425
+
426
+ /** "just now" / "3m ago" / "2h ago" / "5d ago" for an ISO timestamp. */
427
+ export function relativeTime(iso, now = Date.now()) {
428
+ const t = Date.parse(iso);
429
+ if (Number.isNaN(t)) return String(iso ?? "");
430
+ let diff = now - t;
431
+ if (diff < 0) diff = 0;
432
+ const sec = Math.floor(diff / 1000);
433
+ if (sec < 45) return "just now";
434
+ const min = Math.floor(sec / 60);
435
+ if (min < 60) return `${min}m ago`;
436
+ const hr = Math.floor(min / 60);
437
+ if (hr < 24) return `${hr}h ago`;
438
+ return `${Math.floor(hr / 24)}d ago`;
439
+ }
440
+
441
+ /**
442
+ * A verdict-window span. Like fmtDuration but prefers HOURS for spans under
443
+ * ~2 days, so the default 24h window reads "24h" (not "1d").
444
+ */
445
+ function humanizeWindowSpan(ms) {
446
+ const sec = Math.max(0, Math.round(Number(ms) / 1000));
447
+ if (sec === 0) return "0s";
448
+ if (sec % 86400 === 0 && sec / 86400 >= 2) return `${sec / 86400}d`;
449
+ if (sec % 3600 === 0) return `${sec / 3600}h`;
450
+ if (sec % 60 === 0) return `${sec / 60}m`;
451
+ return `${sec}s`;
452
+ }
453
+
454
+ /** Describe the verdict window: "last 24h" when it ends ~now, else a range. */
455
+ export function describeWindow(since, until, now = Date.now()) {
456
+ if (!since || !until) return "recent window";
457
+ const s = Date.parse(since);
458
+ const u = Date.parse(until);
459
+ if (Number.isNaN(s) || Number.isNaN(u)) return `${since} → ${until}`;
460
+ if (Math.abs(now - u) <= 120000) return `last ${humanizeWindowSpan(u - s)}`;
461
+ return `${since} → ${until}`;
462
+ }
463
+
464
+ function logsCommand(fn, id) {
465
+ return fn && id ? `run402 logs ${fn} --request-id ${id}` : null;
466
+ }
467
+
468
+ function topSampleId(row) {
469
+ const recent = row?.samples?.recent;
470
+ if (Array.isArray(recent) && recent[0]?.id) return recent[0].id;
471
+ return row?.samples?.first?.id ?? null;
472
+ }
473
+
474
+ /** The verdict block — printed FIRST so absence-of-signal never reads as health. */
475
+ export function renderVerdict(verdict) {
476
+ const v = verdict || {};
477
+ const L = (label) => ` ${String(label).padEnd(22)}`;
478
+ const lines = [];
479
+
480
+ let header = `Verdict — ${describeWindow(v.window?.since, v.window?.until)}`;
481
+ if (v.compared_release_id) {
482
+ header += ` · comparing ${v.compared_release_id} against baseline ${v.baseline_release_id ?? "(none — first activation)"}`;
483
+ }
484
+ lines.push(header);
485
+
486
+ const newVal = fmtInt(v.new_fingerprints ?? 0);
487
+ lines.push(
488
+ v.compared_release_id
489
+ ? `${L("new error identities")}${newVal} ← first seen under ${v.compared_release_id}`
490
+ : `${L("new error identities")}${newVal}`,
491
+ );
492
+ lines.push(`${L("recurring")}${fmtInt(v.recurring_fingerprints ?? 0)}`);
493
+ lines.push(`${L("invocations in window")}${fmtInt(v.invocations_in_window ?? 0)}`);
494
+
495
+ const full = Number(v.coverage?.full_fidelity_functions ?? 0);
496
+ const coarse = Number(v.coverage?.coarse_functions ?? 0);
497
+ let coverage = `${fmtInt(full)} function(s) full-fidelity · ${fmtInt(coarse)} coarse`;
498
+ if (coarse > 0) coverage += " (redeploy the coarse functions to upgrade fidelity)";
499
+ lines.push(`${L("coverage")}${coverage}`);
500
+
501
+ if (v.row_cap?.at_cap) {
502
+ lines.push(`${L("row cap")}showing up to ${fmtInt(v.row_cap?.limit ?? 0)} — AT CAP; narrow the window (--since / --function) for completeness`);
503
+ }
504
+
505
+ return lines.join("\n");
506
+ }
507
+
508
+ /** One readable line per fingerprint. */
509
+ export function renderListRow(row) {
510
+ const r = row || {};
511
+ const coarse = r.fingerprint_quality === "coarse" ? " [coarse]" : "";
512
+ const msg = truncate(r.message_template, 100);
513
+ return `${r.fingerprint_id} ${r.kind} ×${fmtInt(r.count)} ${r.error_name} fn:${r.function} "${msg}" · ${relativeTime(r.last_seen)}${coarse}`;
514
+ }
515
+
516
+ /** The full human list render: verdict, rows (or the empty note), a drill-down. */
517
+ export function renderHumanList(page) {
518
+ const p = page || {};
519
+ const errors = Array.isArray(p.errors) ? p.errors : [];
520
+ const parts = [renderVerdict(p.verdict), ""];
521
+
522
+ if (errors.length === 0) {
523
+ const inv = Number(p.verdict?.invocations_in_window ?? 0);
524
+ parts.push(`No error fingerprints in window (${fmtInt(inv)} invocation(s) observed).`);
525
+ if (inv === 0) {
526
+ parts.push("Zero errors over zero traffic is absence of signal, not proven health — drive traffic, then re-check.");
527
+ }
528
+ return parts.join("\n");
529
+ }
530
+
531
+ for (const row of errors) parts.push(renderListRow(row));
532
+
533
+ if (p.has_more) {
534
+ parts.push("");
535
+ parts.push(`More rows available — page with --cursor ${p.next_cursor ?? "<next_cursor from --json>"} (cursors are opaque; pass as-is).`);
536
+ }
537
+
538
+ const top = errors[0];
539
+ const cmd =
540
+ (Array.isArray(top?.next_actions) ? top.next_actions.find((x) => x?.type === "fetch_logs")?.command : null) ??
541
+ logsCommand(top?.function, topSampleId(top));
542
+ if (cmd) {
543
+ parts.push("");
544
+ parts.push("Investigate the top fingerprint:");
545
+ parts.push(` ${cmd}`);
546
+ }
547
+
548
+ return parts.join("\n");
549
+ }
550
+
551
+ /** Full detail render: both release attributions, all samples with runnable logs. */
552
+ export function renderHumanDetail(detail) {
553
+ const d = detail || {};
554
+ const parts = [];
555
+ parts.push(`Fingerprint ${d.fingerprint_id}`);
556
+ parts.push(` kind ${d.kind}`);
557
+ parts.push(` error ${d.error_name}`);
558
+ parts.push(` function fn:${d.function}`);
559
+ parts.push(` quality ${d.fingerprint_quality}`);
560
+ if (d.fingerprint_quality === "coarse") {
561
+ parts.push(" This function predates the error side-channel; redeploying it upgrades future fingerprint fidelity (frame-level grouping).");
562
+ }
563
+ parts.push(` count ${fmtInt(d.count)}`);
564
+ parts.push(` first seen ${d.first_seen} (release ${d.first_seen_release_id ?? "unknown"})`);
565
+ parts.push(` last seen ${d.last_seen} (release ${d.last_seen_release_id ?? "unknown"})`);
566
+ if (Array.isArray(d.also_seen_in_functions) && d.also_seen_in_functions.length > 0) {
567
+ parts.push(` also seen in ${d.also_seen_in_functions.map((f) => `fn:${f}`).join(", ")}`);
568
+ }
569
+ parts.push(` message ${d.message_template}`);
570
+
571
+ const frames = Array.isArray(d.stable_frames) ? d.stable_frames : [];
572
+ if (frames.length > 0) {
573
+ parts.push(" stable frames:");
574
+ for (const f of frames) parts.push(` ${f}`);
575
+ }
576
+
577
+ const first = d.samples?.first;
578
+ const recent = Array.isArray(d.samples?.recent) ? d.samples.recent : [];
579
+ parts.push("");
580
+ parts.push("Samples — pinned first occurrence + recent ring (newest first):");
581
+ if (first?.id) {
582
+ parts.push(` first ${first.id} ${first.at ?? ""} (release ${first.release_id ?? "unknown"})`);
583
+ parts.push(` ${logsCommand(d.function, first.id)}`);
584
+ }
585
+ for (const s of recent) {
586
+ if (!s?.id) continue;
587
+ parts.push(` recent ${s.id} ${s.at ?? ""} (release ${s.release_id ?? "unknown"})`);
588
+ parts.push(` ${logsCommand(d.function, s.id)}`);
589
+ }
590
+
591
+ return parts.join("\n");
592
+ }
593
+
594
+ /** Actionable exit-1 render: each new identity with a sample id + logs command. */
595
+ export function renderFailOnNewList(newErrors, newIn, totalNew) {
596
+ const rows = Array.isArray(newErrors) ? newErrors : [];
597
+ const total = Number.isFinite(Number(totalNew)) ? Number(totalNew) : rows.length;
598
+ const parts = [];
599
+ parts.push(`FAIL — ${fmtInt(total)} new error identit${total === 1 ? "y" : "ies"} first seen under ${newIn}:`);
600
+ for (const row of rows) {
601
+ const sid = topSampleId(row);
602
+ parts.push("");
603
+ parts.push(` ${row.fingerprint_id} ${row.kind} ×${fmtInt(row.count)} ${row.error_name} fn:${row.function}`);
604
+ parts.push(` "${truncate(row.message_template, 100)}"`);
605
+ if (sid) {
606
+ parts.push(` sample ${sid}`);
607
+ parts.push(` ${logsCommand(row.function, sid)}`);
608
+ }
609
+ }
610
+ if (total > rows.length) {
611
+ parts.push("");
612
+ parts.push(` … and ${fmtInt(total - rows.length)} more not shown (raise --limit or page with --cursor).`);
613
+ }
614
+ return parts.join("\n");
615
+ }
616
+
617
+ /** Clean-gate render (exit 0). */
618
+ export function renderCleanGate(verdict, newIn, opts = {}) {
619
+ const parts = [];
620
+ const watched = opts.watched
621
+ ? `Watched ${fmtDuration(opts.durationMs)}${opts.polls != null ? ` (${fmtInt(opts.polls)} polls)` : ""} — `
622
+ : "";
623
+ parts.push(`PASS — ${watched}no new error identities first seen under ${newIn}.`);
624
+ if (verdict) parts.push(renderVerdict(verdict));
625
+ return parts.join("\n");
626
+ }