@thehammer/danx-dashboard-mcp 0.1.271 → 0.1.274

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/dist/bridge.js DELETED
@@ -1,1125 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp bridge [--resume-ids <event-id>[,<event-id>...]]` — the ONE
3
- * client of a working session's dashboard event stream.
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's plan event bridge
6
- * (`danxbot/scripts/plan-event-bridge.mjs` in claude-plugins) spawns it once per
7
- * session and owns only process lifecycle, the delivered-id cursor, and posting
8
- * each event (and each failure) into the session's inbox. Everything about the
9
- * dashboard's HTTP contract lives HERE, next to the MCP server that already
10
- * speaks it, and is tested here.
11
- *
12
- * WHAT IT DOES.
13
- * 1. Reads `CLAUDE_CODE_SESSION_ID` from its environment and, for that
14
- * session, the connection record the session's own MCP server wrote when
15
- * it connected the plan (`session-connection.ts`): the dashboard URL, the
16
- * credential's SOURCE, and the credential's FINGERPRINT.
17
- * 2. Resolves that same source through the same resolver the server used
18
- * (`credential.ts`) and refuses to run unless the result fingerprints
19
- * identically — DX-2862, where a bridge authenticated with a DIFFERENT
20
- * credential from its session's tools streamed happily and relayed nothing.
21
- * 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
22
- * The ticket stays in this process.
23
- * 4. DX-3468 — board-coverage narrowing (`403 issuer_cannot_read_plan_boards`
24
- * at mint or admission, mapped to `board_unreadable`; a live stream
25
- * ending `scope_narrowed`) was removed as dead code: DX-3409 made every
26
- * credential team-bound, so a plan session's issuer and every board its
27
- * connected plan covers are always in the same team (see the dashboard's
28
- * `plan-scope.ts` module banner). `409 session_not_connected` at mint
29
- * AND at admission are both mapped to `not_connected`, and
30
- * `end("not_connected")` covers the same failure once the stream is
31
- * already live. This module no longer runs its own duplicate
32
- * board-by-board check before streaming — see DX-2920 and the deleted
33
- * `verifyPlanBoardsReadable`.
34
- * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
35
- * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
36
- * is refused once.
37
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards, cardCount, degraded, inventoryError}`
38
- * once a ticket has been minted and streaming is about to start —
39
- * DX-2970's `fetchPlanInventory`, a single BEST-EFFORT (never a gate)
40
- * read of the connected plan's real board/card counts, purely so a
41
- * consumer can tell "healthy, connected plan has cards" apart from
42
- * "degraded — the connected plan has no cards, or its inventory
43
- * couldn't be read" instead of both reading as an absent/empty field —
44
- * then `{type:"event", id, text}` per event, and ONE final
45
- * `{type:"stopped", reason, detail, fix}` before exiting.
46
- *
47
- * TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
48
- * - `no_connection_record` this session has no connection record to read;
49
- * - `credential_unavailable` its declared credential cannot be resolved here;
50
- * - `credential_mismatch` what resolved is not the server's credential;
51
- * - `not_connected` the mint, or the stream's admission / keep-alive,
52
- * answered `session_not_connected` (409 at mint and
53
- * at admission; `end("not_connected")` once live);
54
- * - `plan_archived` DX-3809 — the plan was archived or deleted while the
55
- * stream was live (`end("plan_archived")`);
56
- * - `unauthorized` the mint answered 401 / 403;
57
- * - `mint_refused` any other non-transient mint refusal (400, 404, …);
58
- * - `mint_bad_response` a 2xx mint whose body is not a ticket;
59
- * - `superseded` / `replaced` (exit 0) — another listener now holds the session;
60
- * - `revoked` the dashboard revoked the ticket or its issuer;
61
- * - `refused` freshly minted tickets were refused twice in a row.
62
- * Transient failures (network, timeout, 408, 429, 5xx) back off and retry: the
63
- * process lives exactly as long as its session, and the plugin ends it.
64
- *
65
- * NEITHER THE ENVIRONMENT NOR ARGV CARRIES THE CREDENTIAL ANY MORE. `--resume-ids`
66
- * is the only argument and holds no secret; the credential is whatever this
67
- * session's own MCP server declared, resolved here from that declaration.
68
- */
69
- import { randomUUID } from "node:crypto";
70
- import { homedir } from "node:os";
71
- import { errorCodeOf } from "./json-body.js";
72
- import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
73
- import { oneLine } from "./one-line.js";
74
- import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
75
- import { readSessionConnection, sessionConnectionPath } from "./session-connection.js";
76
- import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
77
- import { isClaimState } from "@thehammer/danx-session-telemetry";
78
- import { OperatorSessionStats, transcriptPathFromEnv, } from "./session-stats.js";
79
- import { TranscriptTitleReader } from "./session-title.js";
80
- /**
81
- * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
82
- *
83
- * Every `ListenEvent` carries the origin its writer set (`operator` / `agent` /
84
- * `machine`, or `null` for the idle-nudge exemption and an event this listener
85
- * could not parse — see `ListenEvent.origin`'s docblock in `listen.ts`). This
86
- * module is the ONLY place that decides what happens next:
87
- *
88
- * - `operator` (a real human) and `null` (nudge / unreadable) emit AT ONCE,
89
- * exactly as every event did before this card — a session must never wait
90
- * to hear from the person it is working with, and a fault or a nudge must
91
- * never be hidden behind a delay either.
92
- * - `agent` and `machine` are HELD. Every dispatched agent's own writes and
93
- * every server-side guard/reviewer/recovery write share this treatment:
94
- * gate reviews, triage notes, auto-blocks. Held events are combined into
95
- * ONE digest record, flushed the moment either `DIGEST_INTERVAL_MS`
96
- * elapses with no operator event, or an operator event arrives (flushed
97
- * just ahead of it, never after — a session must never read the digest
98
- * as if it postdates the operator's own message).
99
- *
100
- * NOTHING HELD IS EVER DROPPED, AND NOTHING HELD IS EVER DOUBLE-DELIVERED
101
- * EITHER — across a full process restart OR a same-process re-mint. An id is
102
- * added to `delivered` (this run's own resume cursor, seeded from
103
- * `options.resumeIds` and handed to the next mint/re-mint as `--resume-ids`)
104
- * ONLY at the moment it is actually written to stdout — never at the moment it
105
- * is merely held. So a process that dies mid-hold has, by construction, never
106
- * marked those ids delivered: the next process starts from the SAME cursor,
107
- * the dashboard's replay resends exactly the events this process never
108
- * finished relaying, and they are held and flushed again there. This is "the
109
- * existing resumeIds/delivered-id cursor" the card's AC names — no new
110
- * persistence was needed, only NOT marking a held event delivered until it is
111
- * actually flushed.
112
- *
113
- * THE SAME REPLAY CAN ALSO ARRIVE WITHIN ONE STILL-RUNNING PROCESS, on a
114
- * same-process re-mint (a real `lease_expired`, not merely a dropped
115
- * connection): a fresh `runListener()` call starts a FRESH internal duplicate
116
- * guard, seeded only from the `resumeIds` this call hands it — and a held id
117
- * was, by the paragraph above, never in `delivered`. Left unhandled this
118
- * would let the SAME held event be counted into TWO digests once flushed
119
- * twice. `heldIds()` closes this: every `runListener()` call is seeded with
120
- * `[...delivered, ...heldIds()]`, so the fresh listener's own guard
121
- * (`listen.ts`'s `delivered` Set) already knows about a held id and silently
122
- * drops a replay of it before it ever reaches `routeEvent` again —
123
- * `routeEvent`'s own id check on `held` is the second, cheaper layer behind
124
- * that, for defense in depth.
125
- *
126
- * A digest record rides the exact same `{type:"event", id, text}` shape a
127
- * single event does (`id` is the highest id it combines, or `null` if every
128
- * combined event carried none) — the plugin that relays bridge output
129
- * (`plan-event-bridge.mjs`, outside this repo) already forwards any such
130
- * record generically, so a digest needs no change on that side.
131
- *
132
- * A REAL TIMER, NOT `deps.sleep`. Unlike every other timing decision in this
133
- * module (mint backoff, reconnect backoff), the digest interval is not tied to
134
- * stream activity — it must fire even while the stream sits idle between
135
- * events — so it is a plain `setTimeout`, mockable with fake timers in tests,
136
- * rather than routed through the deliberately stream-driven `deps.sleep`.
137
- */
138
- export const DIGEST_INTERVAL_MS = 10 * 60_000;
139
- const DIGEST_PREFIX = "[danx-dashboard bridge]";
140
- /** ONE combined record for every event held since the last flush. */
141
- function formatDigest(entries) {
142
- const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
143
- return [header, ...entries.map((e) => e.text)].join("\n");
144
- }
145
- export const BRIDGE_SUBCOMMAND = "bridge";
146
- /**
147
- * DX-3912 — the longest heartbeat `reason` the bridge sends: one under the dashboard's own
148
- * limit (`HEARTBEAT_REASON_MAX_CHARS` = 200, `src/issues/db/plan-session-heartbeat.ts`),
149
- * because `oneLine` appends a "…" to a truncated string and a longer reason refuses the whole
150
- * heartbeat. `bridge.test.ts` asserts the cap.
151
- */
152
- const HEARTBEAT_REASON_MAX_CODE_POINTS = 199;
153
- export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
154
- // DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
155
- // connected plan's real `boards`/`cardCount` on the `ready` record (see
156
- // `fetchPlanInventory`). This is NOT a revival of DX-2920's deleted
157
- // `verifyPlanBoardsReadable` gate — it never refuses a mint, never blocks
158
- // streaming, and is best-effort (one attempt, no retry/backoff).
159
- export const PLAN_MINE_PATH = "/api/plans/mine";
160
- export const SESSION_ID_HEADER = "x-danx-session-id";
161
- // DX-3028 (AC1) — `POST /api/plan-sessions/me/heartbeat` (DX-2952's server
162
- // route, `plan-sessions-routes.ts#handleHeartbeat`). Every 15s this process
163
- // reports its truthful streaming state; see `sendHeartbeat` below.
164
- export const HEARTBEAT_PATH = "/api/plan-sessions/me/heartbeat";
165
- /** How long one dashboard request may take before it counts as a transient failure. */
166
- export const REQUEST_TIMEOUT_MS = 10_000;
167
- export const MINT_INITIAL_BACKOFF_MS = 1_000;
168
- export const MINT_MAX_BACKOFF_MS = 60_000;
169
- /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
170
- export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
171
- /** DX-3028 (AC1) — the dashboard's own heartbeat cadence; see the card's AC1. */
172
- export const HEARTBEAT_INTERVAL_MS = 15_000;
173
- /** DX-3028 (AC2) — the server-fixable degraded reasons' own periodic retry cadence. */
174
- export const DEGRADED_RETRY_INTERVAL_MS = 5 * 60_000;
175
- /**
176
- * DX-3099 — how many consecutive 15s heartbeat POSTs may fail to LAND
177
- * (network error, timeout, 5xx, 408/429 — never an ordinary 401/403 answer,
178
- * which proves the pipe itself still works) while this process still
179
- * believes it is `"streaming"`, before that counts as evidence the current
180
- * stream connection is dead without having errored — the exact "alive but
181
- * not reporting" signature the investigation found: the SSE connection can
182
- * read as established at the OS level and never trip `listen.ts`'s own
183
- * read-idle timeout, while every outbound heartbeat silently never lands
184
- * (the send call's own result used to be discarded entirely — see the
185
- * card). `3 * HEARTBEAT_INTERVAL_MS` = 45s, deliberately UNDER
186
- * `SESSION_LIVENESS_WINDOW_MS` (60s, `plan-session-heartbeat.ts`) so this
187
- * process self-heals before, or right around, the moment the dashboard
188
- * would itself start reporting the session `unattached` — never a shorter
189
- * freshness window (that constant is untouched) and never a blind
190
- * "restart more eagerly" (no external watchdog, no timer-driven kill): this
191
- * fires only on repeated, OBSERVED delivery failure, self-diagnosed by the
192
- * SAME process that is already streaming. Card: DX-3099 (AC3 forbids both
193
- * of the obvious wrong fixes this constant is not).
194
- */
195
- export const HEARTBEAT_SILENT_FAILURE_THRESHOLD = 3;
196
- const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
197
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
198
- `[--resume-ids <event-id>[,<event-id>...]]`;
199
- /**
200
- * The remedy per terminal reason THIS module knows about. ONE map, so the
201
- * message a session is shown and the message a log carries can never drift.
202
- * `satisfies` makes a reason missing its remedy a compile error here — but
203
- * `KnownStopReason` above is only a SUBSET of `ListenStopReason` (DX-2920
204
- * round 4 already made `ListenStopped.reason` a closed union, not a plain
205
- * `string` — see `listen.ts`), and `ListenStopReason` itself has an `"unknown"`
206
- * member for exactly a newer dashboard's wire reason this build predates
207
- * (normalized there, never passed through raw). So a build older than the
208
- * dashboard it talks to can still reach a real `ListenStopReason` value this
209
- * map has no entry for. `fixForStop` below is deliberately typed to accept a
210
- * plain `string`, wider than `ListenStopReason`, purely as defense in depth
211
- * for a caller reached some other way (and what lets the forward-compat test
212
- * below exercise the fallback with an arbitrary string) — every value that can
213
- * actually reach it through `ListenStopped.reason` is already a real,
214
- * compile-time-checked member of the closed union. `DEFAULT_STOP_FIX` is the
215
- * honest, generic fallback for a reason genuinely outside this list, never a
216
- * silent index miss.
217
- */
218
- export const STOP_FIXES = {
219
- no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
220
- "(a server older than the one that introduced the record writes none)",
221
- credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
222
- credential_mismatch: "restart this Claude Code session so its MCP server and its bridge pick up the same credential, then call plan_connect again",
223
- not_connected: "call plan_connect to connect this session to a plan",
224
- plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
225
- unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
226
- key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
227
- "do not call plan_connect or request access again",
228
- mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
229
- mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
230
- // A newer ticket exists (superseded) or another connection is already using
231
- // this one (replaced) — THIS process is ending because the session is
232
- // already served elsewhere, not because anything is broken.
233
- superseded: "no action needed — a newer ticket was minted for this session, and a fresh bridge is already using it",
234
- replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
235
- revoked: "call plan_connect again to mint a new listener ticket for this session",
236
- refused: "call plan_connect again to mint a new listener ticket for this session",
237
- // DX-3028 — this session id belongs to a worker dispatch, not an operator
238
- // plan session; there is no listener ticket a worker session can ever use.
239
- session_is_worker: "this session id belongs to a worker dispatch; start a new session",
240
- };
241
- export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the bridge";
242
- export function fixForStop(reason) {
243
- return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
244
- }
245
- function positiveInteger(raw) {
246
- const n = Number(raw);
247
- return Number.isSafeInteger(n) && n > 0 && String(n) === raw ? n : null;
248
- }
249
- /** Parse the one optional flag and the one required environment value; anything else is refused. */
250
- export function parseBridgeArgs(argv, env) {
251
- const withResume = argv.length === 2 && argv[0] === "--resume-ids" && argv[1] !== "";
252
- if (argv.length !== 0 && !withResume)
253
- throw new Error(USAGE);
254
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
255
- if (!sessionId)
256
- throw new Error(USAGE);
257
- const resumeIds = withResume ? argv[1].split(",").map(positiveInteger) : [];
258
- if (resumeIds.some((id) => id === null))
259
- throw new Error(USAGE);
260
- const parsed = { sessionId, resumeIds: resumeIds };
261
- const transcriptPath = transcriptPathFromEnv(env);
262
- if (transcriptPath !== null)
263
- parsed.transcriptPath = transcriptPath;
264
- return parsed;
265
- }
266
- /** A start this process cannot make, named the way the session will be told about it. */
267
- export class BridgeStartError extends Error {
268
- // DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
269
- // is always one of `no_connection_record` / `credential_unavailable` /
270
- // `credential_mismatch` (there is no stream yet), so this can be the closed
271
- // reason type the eventual `write({type:"stopped", reason: start.reason,
272
- // ...})` needs it to be.
273
- reason;
274
- fix;
275
- /** DX-3028 — see `BridgeOptions.instanceId`. Always populated: minted before any of the three start-time checks run. */
276
- instanceId;
277
- /** DX-3028 — see `BridgeOptions.watchedPaths`. Empty for `no_connection_record` (there is nothing to watch yet). */
278
- paths;
279
- constructor(reason, detail, fix, instanceId, paths) {
280
- super(detail);
281
- this.name = "BridgeStartError";
282
- this.reason = reason;
283
- this.fix = fix;
284
- this.instanceId = instanceId;
285
- this.paths = paths;
286
- }
287
- }
288
- /**
289
- * The session's connection, as its own MCP server recorded it — and the proof
290
- * that this process holds the SAME credential that server does. Never falls back
291
- * to an ambient env credential: that fallback is precisely DX-2862.
292
- */
293
- const INSTANCE_ID_ENV = "DANX_BRIDGE_INSTANCE_ID";
294
- /**
295
- * DX-3028 — this process's own instance id: read from `DANX_BRIDGE_INSTANCE_ID`
296
- * (minted by the plugin's `start()` before it spawns the run process, and
297
- * carried down to this subcommand's env unchanged), or freshly minted for a
298
- * manual/hand run that has no plugin supplying one. Every stop record and
299
- * every heartbeat names this same id for the life of the process.
300
- */
301
- function resolveInstanceId(env) {
302
- const declared = env[INSTANCE_ID_ENV];
303
- return typeof declared === "string" && declared.trim() !== "" ? declared.trim() : randomUUID();
304
- }
305
- /**
306
- * DX-3028 — the on-disk paths this bridge's authentication depends on, for
307
- * `BridgeOptions.watchedPaths` (see its docblock). A `session-key`
308
- * credential adds its own path; an `env`-sourced one has no file
309
- * (`credential.ts` ~47-48, ~90-95).
310
- */
311
- function watchedPathsFor(record, home) {
312
- const paths = [sessionConnectionPath(record.sessionId, home)];
313
- if (record.source.kind === "session-key")
314
- paths.push(record.source.path);
315
- return paths;
316
- }
317
- export function resolveBridgeOptions(args, env, options = {}) {
318
- const home = options.home ?? homedir();
319
- const instanceId = resolveInstanceId(env);
320
- let record;
321
- try {
322
- record = readSessionConnection(args.sessionId, { home });
323
- }
324
- catch (err) {
325
- throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"), instanceId, []);
326
- }
327
- if (record === null) {
328
- throw new BridgeStartError("no_connection_record", `session ${args.sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"), instanceId, []);
329
- }
330
- // DX-3028 — computed as soon as a connection record exists, even if the
331
- // credential itself later fails to resolve: `credential_unavailable`'s own
332
- // stop record still needs `paths` (the connection record path, at least)
333
- // so the plugin has something to watch for a retry.
334
- const watchedPaths = watchedPathsFor(record, home);
335
- let token;
336
- try {
337
- token = readCredential(record.source, env);
338
- }
339
- catch (err) {
340
- const credentialError = err;
341
- throw new BridgeStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`,
342
- // CredentialError.fix is a required string, never absent — no fallback needed.
343
- credentialError.fix, instanceId, watchedPaths);
344
- }
345
- const fingerprint = credentialFingerprint(token);
346
- if (fingerprint !== record.fingerprint) {
347
- throw new BridgeStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
348
- `danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"), instanceId, watchedPaths);
349
- }
350
- return {
351
- dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""),
352
- token,
353
- sessionId: args.sessionId,
354
- resumeIds: args.resumeIds,
355
- instanceId,
356
- watchedPaths,
357
- ...(args.transcriptPath !== undefined ? { transcriptPath: args.transcriptPath } : {}),
358
- };
359
- }
360
- /**
361
- * One authenticated request, with a timeout, classifying the failures that are
362
- * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
363
- * every request this module makes so they can never disagree about which
364
- * failures are worth retrying.
365
- */
366
- async function request(options, deps, what, url, init = { method: "GET" }) {
367
- const controller = new AbortController();
368
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
369
- try {
370
- let status;
371
- let text;
372
- try {
373
- const headers = {
374
- Authorization: `Bearer ${options.token}`,
375
- Accept: "application/json",
376
- [SESSION_ID_HEADER]: options.sessionId,
377
- };
378
- if (init.body !== undefined)
379
- headers["Content-Type"] = "application/json";
380
- const response = await deps.fetch(url, {
381
- method: init.method,
382
- headers,
383
- body: init.body,
384
- signal: controller.signal,
385
- });
386
- status = response.status;
387
- text = await response.text();
388
- }
389
- catch (err) {
390
- return {
391
- kind: "transient",
392
- detail: controller.signal.aborted
393
- ? `${what} timed out after ${deps.requestTimeoutMs} ms`
394
- : `${what} failed: ${err.message}`,
395
- };
396
- }
397
- if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
398
- return { kind: "transient", detail: `${what} HTTP ${status}` };
399
- }
400
- let body = null;
401
- try {
402
- body = text === "" ? null : JSON.parse(text);
403
- }
404
- catch {
405
- body = null;
406
- }
407
- return { kind: "answer", status, text, body };
408
- }
409
- finally {
410
- clearTimeout(timer);
411
- }
412
- }
413
- /** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
414
- function badTicketReason(body) {
415
- if (typeof body !== "object" || body === null)
416
- return "the body is not a JSON object";
417
- const b = body;
418
- if (typeof b.ticket !== "string" || b.ticket === "")
419
- return "ticket is not a non-empty string";
420
- if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
421
- return "streamPath is not a path";
422
- if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
423
- return "leaseMs is not a positive integer";
424
- }
425
- return null;
426
- }
427
- /** Mint this session's listener ticket, classifying every way that can end. */
428
- export async function mintTicket(options, deps) {
429
- const outcome = await request(options, deps, "ticket mint", `${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
430
- method: "POST",
431
- body: "{}",
432
- });
433
- if (outcome.kind === "transient")
434
- return outcome;
435
- const { status, text, body } = outcome;
436
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
437
- return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
438
- }
439
- // DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
440
- // which every other 409 falls into: this session id belongs to a worker
441
- // dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
442
- // already gives it, DX-2952).
443
- if (status === 409 && errorCodeOf(body) === "session_is_worker") {
444
- return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
445
- }
446
- // DX-4418 - a key a person revoked is not a lapsed one: the detail IS the stop halt (who revoked it, what to do), so the record
447
- // the plugin relays to the session says it in full.
448
- const revocation = keyRevocationOf({ ok: false, status, body });
449
- if (revocation !== null) {
450
- return { kind: "terminal", reason: "key_revoked", detail: keyRevokedHalt(revocation, false) };
451
- }
452
- if (status === 401 || status === 403) {
453
- return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
454
- }
455
- if (status < 200 || status >= 300) {
456
- return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
457
- }
458
- const bad = badTicketReason(body);
459
- if (bad !== null) {
460
- return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
461
- }
462
- const ticket = body;
463
- return {
464
- kind: "ticket",
465
- ticket: ticket.ticket,
466
- streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
467
- leaseMs: ticket.leaseMs,
468
- };
469
- }
470
- /**
471
- * What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory.
472
- *
473
- * DX-3912 — the field names here are the plan resource's WIRE names (`boards`, `card_count`,
474
- * snake_case since DX-3428), the same ones `src/resources/plan/plan-resource.ts` emits; the
475
- * contract test `src/issues/__tests__/plan-inventory-bridge-contract.test.ts` runs this parser
476
- * on the server's own response, so a rename there fails that test instead of silently
477
- * degrading every bridge. A missing field lists the keys the body DID carry, so a rename is
478
- * named in the first message anyone reads.
479
- */
480
- function badInventoryReason(body) {
481
- if (!isRecord(body))
482
- return "the body is not a JSON object";
483
- if (!("boards" in body))
484
- return `boards is missing (response keys: ${Object.keys(body).join(", ")})`;
485
- if (!Array.isArray(body.boards) || body.boards.some((x) => typeof x !== "string")) {
486
- return "boards is not an array of strings";
487
- }
488
- if (!("card_count" in body))
489
- return `card_count is missing (response keys: ${Object.keys(body).join(", ")})`;
490
- const count = body.card_count;
491
- if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
492
- return `card_count is not a non-negative integer (got ${typeof count === "number" ? count : typeof count})`;
493
- }
494
- return null;
495
- }
496
- /**
497
- * DX-2970 — a single BEST-EFFORT read of the connected plan's real
498
- * `boards`/`cardCount`, purely for accurate `ready`-record reporting. NEVER a
499
- * gate: unlike DX-2920's deleted `verifyPlanBoardsReadable`, a failure here
500
- * (network, timeout, non-2xx, unusable body) is NOT retried and NEVER blocks
501
- * or delays streaming — `runBridge` reports `degraded: true` with `boards`/
502
- * `cardCount` both `null` and proceeds exactly as it would have before this
503
- * read existed. The bare (no `fields=`) call is deliberately the cheapest
504
- * read the route offers — the plan's own "cheap scalars," always present on
505
- * every `GET /api/plans/mine` response regardless of `fields`.
506
- */
507
- export async function fetchPlanInventory(options, deps) {
508
- const outcome = await request(options, deps, "plan inventory read", `${options.dashboardUrl}${PLAN_MINE_PATH}`);
509
- if (outcome.kind === "transient")
510
- return { kind: "unavailable", detail: outcome.detail };
511
- const { status, text, body } = outcome;
512
- if (status < 200 || status >= 300) {
513
- return { kind: "unavailable", detail: `plan inventory read HTTP ${status} ${oneLine(text, 300)}` };
514
- }
515
- const bad = badInventoryReason(body);
516
- if (bad !== null) {
517
- return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
518
- }
519
- const b = body;
520
- return { kind: "ok", boards: b.boards, cardCount: b.card_count };
521
- }
522
- /**
523
- * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
524
- * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
525
- * shape matches one the mint route already gives its OWN name to: `409
526
- * session_not_connected`, the SAME shape the mint route uses for "no plan
527
- * connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A session
528
- * with no plan connected is the identical terminal state whether the
529
- * dashboard says so at mint or at the stream's own admission check, and the
530
- * session should see ONE remedy either way.
531
- *
532
- * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
533
- * flattened `detail` string — the second-source-of-truth shape this card's fix
534
- * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
535
- */
536
- function classifyAdmissionRefusal(refusal) {
537
- if (refusal === undefined)
538
- return null;
539
- if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
540
- return { reason: "not_connected", detail: "the session is not connected to a plan" };
541
- }
542
- return null;
543
- }
544
- // DX-3028 (code-review + architecture-review finding) — typed against
545
- // `ListenStopReason` (see that type's own docblock), NOT bare `Set<string>`:
546
- // a typo'd member here used to have zero compile-time signal, silently
547
- // falling into the generic `degrade-server-fixable` default below — exactly
548
- // the kind of drift this safety-critical classification can least afford.
549
- // `satisfies readonly ListenStopReason[]` on each array is what makes a typo
550
- // a build error.
551
- const EXIT_NEVER_RESTART_LIST = [
552
- "no_connection_record",
553
- "not_connected",
554
- "plan_archived",
555
- "superseded",
556
- "replaced",
557
- "session_is_worker",
558
- // DX-4418 - a person revoked the key: the session stops for good, nothing retries.
559
- "key_revoked",
560
- ];
561
- const EXIT_NEVER_RESTART = new Set(EXIT_NEVER_RESTART_LIST);
562
- const DEGRADE_NO_HEARTBEAT_LIST = ["credential_unavailable", "credential_mismatch"];
563
- const DEGRADE_NO_HEARTBEAT = new Set(DEGRADE_NO_HEARTBEAT_LIST);
564
- export function degradePolicyFor(reason) {
565
- if (EXIT_NEVER_RESTART.has(reason))
566
- return "exit";
567
- if (DEGRADE_NO_HEARTBEAT.has(reason))
568
- return "degrade-no-heartbeat";
569
- return "degrade-server-fixable";
570
- }
571
- /**
572
- * One heartbeat POST. Never throws on a failure of the request itself and NEVER stops the bridge on one
573
- * (the one throw is a contract breach: a `key_revoked` answer that does not say who revoked the key, DX-4418; `heartbeatTick`'s
574
- * `.catch` keeps it from taking the bridge down) (card AC1 — "a failed heartbeat never stops the bridge"): `request()`
575
- * itself already classifies every network/timeout/5xx failure as
576
- * `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
577
- * credential) simply comes back as an ordinary non-2xx answer this function
578
- * discards — there is nothing meaningful to retry a heartbeat POST for, and
579
- * the NEXT tick will just try again. A heartbeat refused with 401/403 cannot
580
- * report itself, so `computeListenerHealth`'s staleness fallback
581
- * (`heartbeatIsFresh`) is what eventually reflects it: the row's
582
- * `last_heartbeat_at` stops advancing, and once it falls outside
583
- * `SESSION_LIVENESS_WINDOW_MS` the badge reads `unattached`, NOT
584
- * `credential_mismatch` — `credential_mismatch` is computed only from a
585
- * heartbeat that DID land with `state:"streaming"` and a live ticket whose
586
- * issuer hash differs from the caller's; it has no way to represent "the
587
- * heartbeat call itself was rejected". This gap is real and is recorded on
588
- * the card rather than silently papered over.
589
- */
590
- async function sendHeartbeat(options, deps, connection, stats,
591
- // DX-4461 - the session's current Claude title, read from its transcript (`session-title.ts`).
592
- title) {
593
- const body = { state: connection.state };
594
- if (connection.reason !== undefined)
595
- body.reason = connection.reason;
596
- if (connection.inventory !== undefined)
597
- body.inventory = connection.inventory;
598
- const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
599
- method: "POST",
600
- body: JSON.stringify({ connection: body, ...(stats !== undefined ? { stats } : {}), ...(title !== undefined ? { title } : {}) }),
601
- });
602
- // DX-3099 — this is now the ONE place a heartbeat's own outcome is
603
- // inspected. `request()` still never throws, and a 401/403 is still an
604
- // ordinary answer this function does not retry for (the credential is
605
- // whatever it is; there is nothing to redo about THIS request) — only a
606
- // "transient" outcome (network error, timeout, 5xx, 408/429) means the
607
- // request never landed at all, which is exactly the signal the silent-
608
- // failure counter in `runBridge` below needs. Still never throws.
609
- if (outcome.kind !== "answer")
610
- return { landed: false, status: null, stats: null, revocation: null, title: null };
611
- // DX-2954 — the answer's `stats` (the operator dispatch's stored or
612
- // just-written totals and cursors) feed the session's stats collector.
613
- const ok = outcome.status >= 200 && outcome.status < 300;
614
- // A heartbeat whose carried stats were NOT applied (`statsError`) still
615
- // landed; its `stats` are the stored values, so the collector must re-queue
616
- // rather than treat them as the answer to its write.
617
- const applied = ok && isRecord(outcome.body) && outcome.body.statsError === undefined;
618
- const statsBody = applied && isRecord(outcome.body) && isRecord(outcome.body.stats) ? outcome.body.stats : null;
619
- // DX-3962: an answer from a server that predates attribution stores cursors with no `claims`. Treated exactly like
620
- // an answer with no stats (the older-server path): the collector never starts, the heartbeat still lands, and
621
- // nothing is recorded until the server is deployed. Handing it on would crash the collector at attach.
622
- const answered = statsBody !== null &&
623
- isRecord(statsBody.sourceCursors) &&
624
- isRecord(statsBody.totals) &&
625
- Object.values(statsBody.sourceCursors).every((cursor) => isRecord(cursor) && isClaimState(cursor.claims))
626
- ? statsBody
627
- : null;
628
- // DX-4418 - the dashboard refuses the beat of a session whose key a person revoked, and says so.
629
- // DX-4461 - the dashboard's stored title for this session; an answer without one is an older dashboard's.
630
- const storedTitle = ok && isRecord(outcome.body) && typeof outcome.body.title === "string" ? outcome.body.title : null;
631
- return {
632
- landed: true,
633
- status: outcome.status,
634
- stats: answered,
635
- revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }),
636
- title: storedTitle,
637
- };
638
- }
639
- function isRecord(value) {
640
- return typeof value === "object" && value !== null && !Array.isArray(value);
641
- }
642
- /**
643
- * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
644
- * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
645
- */
646
- export async function runBridge(options, deps) {
647
- const delivered = capResumeIds(options.resumeIds);
648
- // DX-3028 (AC1) — this process's own truthful state, reported every
649
- // HEARTBEAT_INTERVAL_MS regardless of what the mint/stream loop is doing.
650
- // `onState` (threaded into `runListener` below) is the ONLY thing that ever
651
- // sets "streaming"; everything else (mint backoff, degraded idling) is
652
- // "reconnecting" or "stopped". `heartbeatSuppressed` covers the two
653
- // start-time-unauthenticatable degrade reasons (card AC2): the process has
654
- // no credential it can report WITH, so it sends nothing at all rather than
655
- // an unauthenticated POST that would just be refused.
656
- let connectionState = "reconnecting";
657
- let stoppedReason = null;
658
- let heartbeatSuppressed = false;
659
- // DX-3912 — `null` is "the inventory read succeeded"; a string is why it did not.
660
- let lastInventoryError = null;
661
- // DX-3099 — consecutive heartbeat POSTs that failed to LAND while this
662
- // process still believed `connectionState === "streaming"`. Reset to 0 the
663
- // moment a heartbeat lands, or the moment state leaves "streaming" for any
664
- // other reason (a fresh connect cycle earns a clean slate — see the `for
665
- // (;;)` loop below). `forceReconnect` is the CURRENT connectOnce attempt's
666
- // own abort, supplied fresh by `listen.ts` on every attempt via
667
- // `onConnectionAttempt` below; calling it after that attempt has already
668
- // ended is a harmless no-op.
669
- let heartbeatFailureStreak = 0;
670
- // DX-4418 - the stop notice for a revoked key goes out once; every later beat is refused the same way.
671
- let revocationAnnounced = false;
672
- let forceReconnect = null;
673
- // DX-2954 — the session's stats ride this same heartbeat. Created ONCE per
674
- // process, so an in-process re-mint never touches its state.
675
- const sessionStats = new OperatorSessionStats(options.transcriptPath ?? null, deps.makeStatsWatcher);
676
- const connectionNow = () => connectionState === "streaming"
677
- ? lastInventoryError === null
678
- ? { state: "streaming", inventory: "ok" }
679
- : { state: "streaming", inventory: "unavailable", reason: oneLine(lastInventoryError, HEARTBEAT_REASON_MAX_CODE_POINTS) }
680
- : connectionState === "stopped"
681
- ? { state: "stopped", reason: stoppedReason ?? undefined }
682
- : { state: "reconnecting" };
683
- // DX-4461 - a rename reaches the dashboard without the agent: the title in the session's transcript rides the heartbeat
684
- // when it differs from the one the dashboard last answered with. Only the LATEST landed answer counts: one without a
685
- // title (an older dashboard, including one rolled back to mid-session, or a refusal) stops the title until an answer
686
- // carries one again, since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
687
- const titleReader = options.transcriptPath !== undefined ? new TranscriptTitleReader(options.transcriptPath) : null;
688
- let dashboardTitle;
689
- let lastTitleError = null;
690
- const titleToReport = () => {
691
- if (titleReader === null || dashboardTitle === undefined)
692
- return undefined;
693
- // The title is cosmetic and the heartbeat is liveness: a transcript the title cannot be read from costs the title
694
- // (said once per distinct failure), never the beat.
695
- try {
696
- const title = titleReader.read();
697
- lastTitleError = null;
698
- return title !== null && title !== dashboardTitle ? title : undefined;
699
- }
700
- catch (err) {
701
- const message = err instanceof Error ? err.message : String(err);
702
- if (message !== lastTitleError)
703
- process.stderr.write(`session title: ${message}\n`);
704
- lastTitleError = message;
705
- return undefined;
706
- }
707
- };
708
- const heartbeatTick = () => {
709
- if (heartbeatSuppressed)
710
- return;
711
- const title = titleToReport();
712
- void sessionStats.tick(async (stats) => {
713
- const answer = await sendHeartbeat(options, deps, connectionNow(), stats, title);
714
- if (answer.landed)
715
- dashboardTitle = answer.title ?? undefined;
716
- // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once, as an ordinary event
717
- // line (the same delivery the silent-failure notice below uses). Its next dashboard call says the same.
718
- if (answer.revocation !== null && !revocationAnnounced) {
719
- revocationAnnounced = true;
720
- routeEvent({ type: "event", id: null, text: keyRevokedHalt(answer.revocation, false), origin: null });
721
- }
722
- return answer;
723
- }).then((landed) => {
724
- // DX-3099 — only counts while THIS process still believes it is
725
- // streaming: that is the exact desync the card found (the client
726
- // believes it is fine; the dashboard has nothing recent for it). A
727
- // failure while reconnecting/degraded is not new information — the
728
- // existing mint-backoff / degrade-retry machinery already owns
729
- // recovery there.
730
- if (connectionState !== "streaming" || landed) {
731
- heartbeatFailureStreak = 0;
732
- return;
733
- }
734
- heartbeatFailureStreak += 1;
735
- if (heartbeatFailureStreak < HEARTBEAT_SILENT_FAILURE_THRESHOLD)
736
- return;
737
- heartbeatFailureStreak = 0;
738
- // AC4 — the session finds out, rather than continuing to believe it is
739
- // connected: an ordinary event line, delivered immediately (`origin:
740
- // null` skips D4's agent/machine hold — see `routeEvent`'s docblock),
741
- // which the plugin already relays into the session's inbox unchanged
742
- // (see this module's own docblock).
743
- routeEvent({
744
- type: "event",
745
- id: null,
746
- text: oneLine(`${DIGEST_PREFIX} this session's listener stopped reaching the dashboard for ` +
747
- `${Math.round((HEARTBEAT_SILENT_FAILURE_THRESHOLD * HEARTBEAT_INTERVAL_MS) / 1000)}s while it still ` +
748
- `believed it was streaming — forcing a fresh connection now.`),
749
- origin: null,
750
- });
751
- // AC2 — force the CURRENT connectOnce attempt to end now, rather than
752
- // wait on listen.ts's own read-idle timeout (which this exact failure
753
- // mode can outlive indefinitely — see the card: the connection reads
754
- // as established and never stops producing keep-alive bytes on the
755
- // read side, only the outbound heartbeat silently never lands). The
756
- // new attempt reconnects with the SAME ticket; the dashboard's own
757
- // "ONE OPEN STREAM PER TICKET" rule (`attachListener`,
758
- // `plan-session-listeners.ts`) then tells the OLD, now-superseded
759
- // registry entry to end (`"replaced"`) — which is what actually
760
- // releases the exclusive claim, as a natural side effect of THIS SAME
761
- // PROCESS reconnecting, never an external watchdog and never a second
762
- // process racing the first.
763
- forceReconnect?.();
764
- }).catch((err) => {
765
- // DX-3962: a stats failure must never take the bridge down. `heartbeatTick` is fired without being awaited, so
766
- // a throw here would be an unhandled rejection that kills the process and with it the session's plan events.
767
- process.stderr.write(`operator session stats: heartbeat tick failed: ${err instanceof Error ? err.message : String(err)}\n`);
768
- });
769
- };
770
- const heartbeat = deps.scheduleInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
771
- async function settle(reason, detail, code, extra) {
772
- // DX-2730 D4 — every settle goes through here, so this is the ONE place
773
- // that cancels a pending digest timer. CANCELLED, never flushed: for
774
- // `superseded`/`replaced` a newer process is already streaming this same
775
- // session and will receive (and hold, and eventually flush) these same
776
- // held events itself via the dashboard's replay — flushing here too would
777
- // double-deliver the digest. For every other reason, whatever is still
778
- // held was never marked delivered (see `emitNow`), so a future process
779
- // (or this same one, after a degrade retry) picks it up the same way — a
780
- // live, un-fired `setTimeout` would otherwise also keep this process's
781
- // event loop alive on top of the heartbeat interval, for no reason.
782
- if (digestTimer !== null) {
783
- clearTimeout(digestTimer);
784
- digestTimer = null;
785
- }
786
- const policy = degradePolicyFor(reason);
787
- const degraded = policy !== "exit";
788
- const fix = extra?.fix ?? fixForStop(reason);
789
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
790
- if (policy === "exit") {
791
- heartbeat.stop();
792
- sessionStats.stop();
793
- return { kind: "exit", code };
794
- }
795
- stoppedReason = reason;
796
- connectionState = "stopped";
797
- if (policy === "degrade-no-heartbeat") {
798
- // DX-3028 (AC2) — "Three of these cannot report anything": this process
799
- // has no credential it can authenticate a heartbeat WITH, so it sends
800
- // none (`heartbeatSuppressed`) and has no basis to retry the mint
801
- // itself either — it idles until the plugin, watching `watchedPaths`
802
- // for a change, kills and restarts it fresh (a new process re-reads
803
- // the credential from scratch). Never resolves on its own.
804
- heartbeatSuppressed = true;
805
- await new Promise(() => {
806
- /* deliberately never settles — see the comment above */
807
- });
808
- }
809
- // degrade-server-fixable: keep heartbeating `stopped`, and retry the
810
- // mint/stream on our own after DEGRADED_RETRY_INTERVAL_MS, on top of the
811
- // plugin's own watched-path restart (harmless overlap — whichever fires
812
- // first wins; a restarted process just re-enters this same loop clean).
813
- heartbeatSuppressed = false;
814
- await deps.sleep(DEGRADED_RETRY_INTERVAL_MS);
815
- return { kind: "retry" };
816
- }
817
- let backoff = MINT_INITIAL_BACKOFF_MS;
818
- let refusedInRow = 0;
819
- let readyEmitted = false;
820
- // DX-2730 D4 — digest state, declared OUTSIDE the mint/reconnect loop below
821
- // so a re-mint (a lapsed lease, still the SAME process) never loses a
822
- // pending hold — see the module docblock for the restart case, which the
823
- // resume cursor handles instead.
824
- let held = [];
825
- let digestTimer = null;
826
- function markDelivered(id) {
827
- if (id === null)
828
- return;
829
- delivered.push(id);
830
- if (delivered.length > DELIVERED_ID_MEMORY)
831
- delivered.shift();
832
- }
833
- /** Write an event downstream at once, and mark its id delivered — never before now. */
834
- function emitNow(output) {
835
- markDelivered(output.id);
836
- deps.write(output);
837
- }
838
- /** Combine every held event into ONE record and emit it, if there is anything to combine. */
839
- function flushDigest() {
840
- if (digestTimer !== null) {
841
- clearTimeout(digestTimer);
842
- digestTimer = null;
843
- }
844
- if (held.length === 0)
845
- return;
846
- const entries = held;
847
- held = [];
848
- const ids = entries.map((e) => e.id).filter((id) => id !== null);
849
- emitNow({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), text: formatDigest(entries), origin: null });
850
- }
851
- /** Every id currently held, unflushed — also fed back into the next `runListener` call's
852
- * `resumeIds` below, so a re-mint's fresh listener knows these were already seen and its
853
- * OWN duplicate guard (`listen.ts`'s `delivered` Set) silently drops a replay of one,
854
- * rather than it reaching here a second time. */
855
- function heldIds() {
856
- return held.map((e) => e.id).filter((id) => id !== null);
857
- }
858
- /**
859
- * DX-2730 D4 — the ONE place that decides immediate vs. held-for-digest.
860
- * `operator` and `null` (nudge / unreadable — see `ListenEvent.origin`'s
861
- * docblock) flush any pending digest first (never let a digest arrive AFTER
862
- * the operator event that should have superseded it) and then emit at once.
863
- * `agent` / `machine` are appended to the hold, arming the interval timer
864
- * only for the FIRST held event since the last flush — a later addition
865
- * must never push the deadline out, or the "at most every 10 minutes"
866
- * guarantee would erode into "10 minutes after the last event," which could
867
- * never fire while events keep arriving.
868
- *
869
- * DEDUPED BY ID (defense in depth): a held event's id is not added to
870
- * `delivered` until it is actually flushed (see `emitNow`), which is what
871
- * lets a crashed-and-restarted PROCESS recover it via the dashboard's own
872
- * replay. The same replay can also legitimately re-arrive WITHIN one still-
873
- * running process — a re-mint after a real `lease_expired` starts a fresh
874
- * `runListener` whose own duplicate guard is seeded from `resumeIds` alone,
875
- * and a still-held id was never in `delivered` to seed it with. `resumeIds`
876
- * below closes that for the ordinary case by handing the fresh listener
877
- * every held id too; this check is the second, cheaper layer for anything
878
- * that reaches here anyway — never trust a single guard to hold alone.
879
- */
880
- function routeEvent(output) {
881
- if (output.origin === "agent" || output.origin === "machine") {
882
- if (output.id !== null && heldIds().includes(output.id))
883
- return;
884
- held.push(output);
885
- digestTimer ??= setTimeout(flushDigest, DIGEST_INTERVAL_MS);
886
- return;
887
- }
888
- flushDigest();
889
- emitNow(output);
890
- }
891
- for (;;) {
892
- connectionState = "reconnecting";
893
- // DX-3099 — a fresh cycle earns a clean slate: any abort captured from a
894
- // now-abandoned prior attempt is dropped (calling it would be a harmless
895
- // no-op anyway) and the failure streak starts over at 0.
896
- forceReconnect = null;
897
- heartbeatFailureStreak = 0;
898
- const minted = await mintTicket(options, deps);
899
- if (minted.kind === "terminal") {
900
- const outcome = await settle(minted.reason, minted.detail, 1);
901
- if (outcome.kind === "exit")
902
- return outcome.code;
903
- refusedInRow = 0;
904
- continue;
905
- }
906
- if (minted.kind === "transient") {
907
- await deps.sleep(backoff);
908
- backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
909
- continue;
910
- }
911
- // DX-2920 — `ready` fires once, on the FIRST successful mint, now that
912
- // board reach is proven by the dashboard's own mint refusal rather than a
913
- // client-side check run here (see the module docblock + the deleted
914
- // `verifyPlanBoardsReadable`). A re-mint (a lapsed lease) never repeats it.
915
- // DX-2970 — the inventory read (`fetchPlanInventory`) runs on this SAME
916
- // first-mint-only path, for the same reason: it is a property of the
917
- // credential and the plan, and a re-mint changes neither. Best-effort —
918
- // never gates the `for(;;)` loop, never retried, never delays streaming.
919
- if (!readyEmitted) {
920
- readyEmitted = true;
921
- const inventory = await fetchPlanInventory(options, deps);
922
- // DX-3028 (AC1, code-review + architecture-review finding) — this is
923
- // the ONE place `lastInventoryError` is ever written; the 15s heartbeat
924
- // reads it on every "streaming" tick (see `heartbeatTick` above). Must
925
- // agree with the SAME `ready` record's own `degraded` decision below —
926
- // a heartbeat reporting `inventory:"ok"` while the `ready` record just
927
- // told the operator `degraded:true` would be exactly the untruthful
928
- // state AC1 exists to prevent.
929
- lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
930
- if (inventory.kind === "ok") {
931
- deps.write({
932
- type: "ready",
933
- boards: inventory.boards,
934
- cardCount: inventory.cardCount,
935
- degraded: inventory.boards.length === 0 || inventory.cardCount === 0,
936
- inventoryError: null,
937
- });
938
- }
939
- else {
940
- deps.write({ type: "ready", boards: null, cardCount: null, degraded: true, inventoryError: inventory.detail });
941
- }
942
- }
943
- backoff = MINT_INITIAL_BACKOFF_MS;
944
- const run = { stopped: null, emitted: false };
945
- const startedAt = deps.now();
946
- // DX-2829 — `runListener`'s own return value (0 for a benign takeover, 1
947
- // otherwise) is intentionally discarded here, never reused as this
948
- // function's exit code: `runBridge` computes ITS OWN exit code below from
949
- // `stopped.reason` via the `stop()` helper above, because a bridge-level
950
- // stop can happen for reasons `runListener` never sees at all (mint
951
- // failures, the refused-ticket retry budget, `classifyAdmissionRefusal`'s
952
- // reclassification) — `stop()` is the one place that has to decide the
953
- // exit code for ALL of them, not just the stream-reader's own outcomes.
954
- // Reusing `runListener`'s return value here would be redundant at best
955
- // (the two happen to agree for `superseded`/`replaced`) and wrong at worst
956
- // wherever a bridge-level reason diverges from the stream-level one it
957
- // was built from.
958
- await runListener(
959
- // DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
960
- // not-yet-flushed event was never added to `delivered` (see `emitNow`),
961
- // so without this a fresh listener after a re-mint would not recognize
962
- // a replay of it as already-seen and would hold (and eventually
963
- // digest) it a second time. See `routeEvent`'s docblock. (If this
964
- // combined list ever exceeded `DELIVERED_ID_MEMORY`, `runListener`'s own
965
- // `slice(-DELIVERED_ID_MEMORY)` would evict the OLDEST entries first —
966
- // i.e. `delivered`'s ids before `heldIds()`'s, which is the right order:
967
- // a held id is always the more recent of the two and the one a near-
968
- // term replay is actually likely to touch.)
969
- { streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered, ...heldIds()] }, {
970
- ...deps,
971
- write: (output) => {
972
- if (output.type === "stopped") {
973
- run.stopped = output;
974
- return;
975
- }
976
- run.emitted = true;
977
- routeEvent(output);
978
- },
979
- // DX-3028 (AC1) — the only place `connectionState` ever becomes
980
- // "streaming": `listen.ts`'s `connectOnce` calls this the moment the
981
- // SSE connection is actually open, and the outer `for(;;)` above
982
- // already resets it to "reconnecting" before every mint attempt.
983
- onState: (state) => {
984
- connectionState = state;
985
- },
986
- // DX-3099 — captures the CURRENT connectOnce attempt's own abort, so
987
- // the heartbeat-failure self-heal above always targets exactly the
988
- // in-flight connection, never a stale one from an earlier attempt.
989
- onConnectionAttempt: (abort) => {
990
- forceReconnect = abort;
991
- },
992
- });
993
- const stopped = run.stopped;
994
- if (stopped === null)
995
- throw new Error("the stream reader ended without a stop record");
996
- // DX-2920 (round 4) — direct equality, not `TAKEOVER_REASONS.has(...)`:
997
- // `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
998
- // for the compiler — leaving it a `Set` lookup here would keep every
999
- // downstream branch's exhaustive narrowing (which is what makes the final
1000
- // fallthrough's `settle(stopped.reason, ...)` call type-check against the
1001
- // closed `ListenStopReason` overload) from working.
1002
- if (stopped.reason === "superseded" || stopped.reason === "replaced") {
1003
- const outcome = await settle(stopped.reason, stopped.detail, 0);
1004
- if (outcome.kind === "exit")
1005
- return outcome.code;
1006
- refusedInRow = 0;
1007
- continue;
1008
- }
1009
- if (stopped.reason === "lease_expired") {
1010
- refusedInRow = 0;
1011
- continue;
1012
- }
1013
- if (stopped.reason === "refused") {
1014
- const classified = classifyAdmissionRefusal(stopped.refusal);
1015
- if (classified !== null) {
1016
- // DX-2920 — a NAMED admission refusal is terminal at once, exactly
1017
- // like its mint-time counterpart — never counted toward the generic
1018
- // refused-ticket retry budget below, which exists for an admission
1019
- // refusal with no more specific explanation than "not admitted".
1020
- const outcome = await settle(classified.reason, classified.detail, 1);
1021
- if (outcome.kind === "exit")
1022
- return outcome.code;
1023
- refusedInRow = 0;
1024
- continue;
1025
- }
1026
- }
1027
- if (stopped.reason === "refused") {
1028
- const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
1029
- refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
1030
- if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
1031
- const outcome = await settle("refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`, 1);
1032
- if (outcome.kind === "exit")
1033
- return outcome.code;
1034
- refusedInRow = 0;
1035
- }
1036
- continue;
1037
- }
1038
- {
1039
- const outcome = await settle(stopped.reason, stopped.detail, 1);
1040
- if (outcome.kind === "exit")
1041
- return outcome.code;
1042
- refusedInRow = 0;
1043
- continue;
1044
- }
1045
- }
1046
- }
1047
- /**
1048
- * DX-3028 — the ONE real `scheduleInterval` implementation, shared by
1049
- * `runBridge`'s deps AND `runBridgeCommand`'s own start-time
1050
- * `degrade-no-heartbeat` keep-alive (see the comment at that call site for
1051
- * why the latter needs one too). A single real `setInterval`/`clearInterval`
1052
- * pair, so the two call sites can never drift into two different ideas of
1053
- * what "the real process's interval" means.
1054
- */
1055
- function realScheduleInterval(tick, ms) {
1056
- const timer = setInterval(tick, ms);
1057
- return { stop: () => clearInterval(timer) };
1058
- }
1059
- /** The bin's `bridge` subcommand, wired to the real process. */
1060
- export async function runBridgeCommand(argv, env = process.env,
1061
- /** Test seam: the home the session's connection record is read from. */
1062
- resolveFrom = {}) {
1063
- const write = (output) => {
1064
- process.stdout.write(`${JSON.stringify(output)}\n`);
1065
- };
1066
- let args;
1067
- try {
1068
- args = parseBridgeArgs(argv, env);
1069
- }
1070
- catch (err) {
1071
- process.stderr.write(`${err.message}\n`);
1072
- return 2;
1073
- }
1074
- let options;
1075
- try {
1076
- options = resolveBridgeOptions(args, env, resolveFrom);
1077
- }
1078
- catch (err) {
1079
- const start = err;
1080
- // DX-3028 (AC2) — a start-time failure still reports itself the way every
1081
- // other stop does — one stop record the plugin relays into the session
1082
- // (now carrying `paths`/`instanceId`/`degraded`) — AND on stderr, for the
1083
- // case where there is no session to relay into (a hand run).
1084
- const policy = degradePolicyFor(start.reason);
1085
- const degraded = policy !== "exit";
1086
- write({ type: "stopped", reason: start.reason, detail: start.message, fix: start.fix, instanceId: start.instanceId, paths: start.paths, degraded });
1087
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
1088
- if (!degraded)
1089
- return 1;
1090
- // DX-3028 (AC2, architecture-review finding) — `credential_unavailable` /
1091
- // `credential_mismatch`: this process has no credential it can
1092
- // authenticate a heartbeat with (see `settle`'s identical
1093
- // `degrade-no-heartbeat` branch), so it sends none — but it MUST still
1094
- // genuinely idle until the plugin kills and restarts it fresh on a
1095
- // watched-path change, exactly like the runtime `settle()` path does.
1096
- // The runtime path stays alive because ITS heartbeat `setInterval` (in
1097
- // `runBridge`, started before its mint loop) is already registered with
1098
- // libuv by the time it degrades; THIS path is reached before `runBridge`
1099
- // (and its interval) ever exists, so a bare `return new Promise(() => {})`
1100
- // here registers no handle at all — Node has nothing left pending once
1101
- // the call stack unwinds and the process exits almost immediately,
1102
- // defeating the "idles forever" contract `BridgeOptions.watchedPaths`
1103
- // exists to serve (DX-2953's watchdog only ever gets a chance to act on
1104
- // a process that is actually still running). `realScheduleInterval()`
1105
- // gives it the same kind of keep-alive handle the runtime path gets for
1106
- // free, ticking a no-op (there is nothing truthful to heartbeat here).
1107
- realScheduleInterval(() => { }, HEARTBEAT_INTERVAL_MS);
1108
- // `return`ed directly (never `await`ed then fallen through) so
1109
- // TypeScript's definite-assignment check for `options` below sees this
1110
- // branch as never reaching that line, which is also true at runtime —
1111
- // this promise never settles.
1112
- return new Promise(() => {
1113
- /* deliberately never settles */
1114
- });
1115
- }
1116
- return runBridge(options, {
1117
- fetch: (input, init) => fetch(input, init),
1118
- write,
1119
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1120
- now: Date.now,
1121
- readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
1122
- requestTimeoutMs: REQUEST_TIMEOUT_MS,
1123
- scheduleInterval: realScheduleInterval,
1124
- });
1125
- }