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 +6 -0
- package/lib/errors.mjs +626 -0
- package/lib/errors.test.mjs +217 -0
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +10 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +10 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/errors.d.ts +77 -0
- package/sdk/dist/namespaces/errors.d.ts.map +1 -0
- package/sdk/dist/namespaces/errors.js +256 -0
- package/sdk/dist/namespaces/errors.js.map +1 -0
- package/sdk/dist/namespaces/errors.types.d.ts +220 -0
- package/sdk/dist/namespaces/errors.types.d.ts.map +1 -0
- package/sdk/dist/namespaces/errors.types.js +25 -0
- package/sdk/dist/namespaces/errors.types.js.map +1 -0
- package/sdk/dist/namespaces/functions.types.d.ts +12 -0
- package/sdk/dist/namespaces/functions.types.d.ts.map +1 -1
- package/sdk/dist/scoped.d.ts +14 -0
- package/sdk/dist/scoped.d.ts.map +1 -1
- package/sdk/dist/scoped.js +23 -0
- package/sdk/dist/scoped.js.map +1 -1
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
|
+
}
|