@thehammer/danx-dashboard-mcp 0.1.88 → 0.1.90

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 CHANGED
@@ -20,29 +20,59 @@
20
20
  * credential from its session's tools streamed happily and relayed nothing.
21
21
  * 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
22
22
  * The ticket stays in this process.
23
- * 4. VERIFIES THE REACH IT JUST BOUGHT: the credential must be able to read
24
- * every board the connected plan's cards live on, because the stream admits
25
- * an event only when the ticket issuer's board allowlist covers that
26
- * event's board (`isVisibleToStream`, dashboard). A board it cannot read is
27
- * a board whose events would vanish without a word, so it is a terminal,
28
- * named outcome instead.
23
+ * 4. DX-2920 — board-coverage reach is a SERVER-ENFORCED property now, never a
24
+ * client-side verify: the stream admits an event only when the ticket
25
+ * issuer's board allowlist covers that event's board (`isVisibleToStream`,
26
+ * dashboard), and the dashboard itself (DX-2863) refuses to hand out a
27
+ * ticket, refuses to admit a connection, and ends a live stream the instant
28
+ * that stops being true — `403 issuer_cannot_read_plan_boards` at MINT
29
+ * (`mintTicket`) AND at stream ADMISSION (`classifyAdmissionRefusal`,
30
+ * reading `listen.ts`'s structured `refusal`) are BOTH mapped to
31
+ * `board_unreadable`, naming the boards; `409 session_not_connected` at
32
+ * mint AND at admission are both mapped to `not_connected`; and
33
+ * `end("scope_narrowed", boards)` / `end("not_connected")` cover the
34
+ * same two failures once the stream is already live — `end`'s own
35
+ * overloaded signature in `plan-session-stream.ts` REQUIRES `boards` for
36
+ * `scope_narrowed` at the type level (round 3), and `listen.ts` parses
37
+ * them off the wire the same way it parses `reason`, so this module can
38
+ * name them in `scope_narrowed`'s detail/fix exactly like it already
39
+ * does for `board_unreadable`. This module no longer runs its own
40
+ * duplicate board-by-board check before streaming — see DX-2920 and the
41
+ * deleted `verifyPlanBoardsReadable`.
29
42
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
30
43
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
31
44
  * is refused once.
32
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards}` once the stream
33
- * is live and verified, `{type:"event", id, text}` per event, and ONE final
34
- * `{type:"stopped", reason, detail, fix}` before exiting.
45
+ * 6. Writes JSON Lines to stdout: ONE `{type:"ready"}` once a ticket has been
46
+ * minted and streaming is about to start, `{type:"event", id, text}` per
47
+ * event, and ONE final `{type:"stopped", reason, detail, fix}` before exiting.
35
48
  *
36
49
  * TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
37
50
  * - `no_connection_record` this session has no connection record to read;
38
51
  * - `credential_unavailable` its declared credential cannot be resolved here;
39
52
  * - `credential_mismatch` what resolved is not the server's credential;
40
- * - `not_connected` the mint answered 409 `session_not_connected`;
41
- * - `unauthorized` the mint or a scope read answered 401 / 403;
53
+ * - `not_connected` the mint, or the stream's admission / keep-alive,
54
+ * answered `session_not_connected` (409 at mint and
55
+ * at admission; `end("not_connected")` once live);
56
+ * - `unauthorized` the mint answered 401 / 403 for a reason OTHER
57
+ * than an unreadable board;
42
58
  * - `mint_refused` any other non-transient mint refusal (400, 404, …);
43
59
  * - `mint_bad_response` a 2xx mint whose body is not a ticket;
44
- * - `board_unreadable` the credential cannot read a board of this plan;
45
- * - `scope_check_failed` the plan's boards could not be established;
60
+ * - `board_unreadable` the mint OR the stream's admission was refused
61
+ * `403 issuer_cannot_read_plan_boards`, naming the
62
+ * boards this credential cannot read (DX-2920);
63
+ * - `scope_narrowed` a LIVE stream ended because the plan grew, or the
64
+ * credential's allowlist shrank past, a board this
65
+ * credential can no longer read — `boards` names
66
+ * them, parsed from the dashboard's own `end`
67
+ * payload by `listen.ts` the same way `reason`
68
+ * itself is parsed, and `detail`/`fix` are built
69
+ * dynamically from them (DX-2920 round 3 —
70
+ * `boardUnreadableDetail` / `scopeNarrowedFix`),
71
+ * never a static generic message;
72
+ * - `bad_end_payload` a `scope_narrowed` end whose `boards` array was
73
+ * missing or malformed — a PROTOCOL error, never
74
+ * silently downgraded to a boards-less
75
+ * `scope_narrowed` stop (DX-2920 round 3);
46
76
  * - `superseded` / `replaced` (exit 0) — another listener now holds the session;
47
77
  * - `revoked` the dashboard revoked the ticket or its issuer;
48
78
  * - `refused` freshly minted tickets were refused twice in a row.
@@ -54,14 +84,78 @@
54
84
  * session's own MCP server declared, resolved here from that declaration.
55
85
  */
56
86
  import { homedir } from "node:os";
87
+ import { errorCodeOf } from "./json-body.js";
57
88
  import { oneLine } from "./one-line.js";
58
89
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
59
90
  import { readSessionConnection } from "./session-connection.js";
60
- import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
91
+ import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
92
+ /**
93
+ * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
94
+ *
95
+ * Every `ListenEvent` carries the origin its writer set (`operator` / `agent` /
96
+ * `machine`, or `null` for the idle-nudge exemption and an event this listener
97
+ * could not parse — see `ListenEvent.origin`'s docblock in `listen.ts`). This
98
+ * module is the ONLY place that decides what happens next:
99
+ *
100
+ * - `operator` (a real human) and `null` (nudge / unreadable) emit AT ONCE,
101
+ * exactly as every event did before this card — a session must never wait
102
+ * to hear from the person it is working with, and a fault or a nudge must
103
+ * never be hidden behind a delay either.
104
+ * - `agent` and `machine` are HELD. Every dispatched agent's own writes and
105
+ * every server-side guard/reviewer/recovery write share this treatment:
106
+ * gate reviews, triage notes, auto-blocks. Held events are combined into
107
+ * ONE digest record, flushed the moment either `DIGEST_INTERVAL_MS`
108
+ * elapses with no operator event, or an operator event arrives (flushed
109
+ * just ahead of it, never after — a session must never read the digest
110
+ * as if it postdates the operator's own message).
111
+ *
112
+ * NOTHING HELD IS EVER DROPPED, AND NOTHING HELD IS EVER DOUBLE-DELIVERED
113
+ * EITHER — across a full process restart OR a same-process re-mint. An id is
114
+ * added to `delivered` (this run's own resume cursor, seeded from
115
+ * `options.resumeIds` and handed to the next mint/re-mint as `--resume-ids`)
116
+ * ONLY at the moment it is actually written to stdout — never at the moment it
117
+ * is merely held. So a process that dies mid-hold has, by construction, never
118
+ * marked those ids delivered: the next process starts from the SAME cursor,
119
+ * the dashboard's replay resends exactly the events this process never
120
+ * finished relaying, and they are held and flushed again there. This is "the
121
+ * existing resumeIds/delivered-id cursor" the card's AC names — no new
122
+ * persistence was needed, only NOT marking a held event delivered until it is
123
+ * actually flushed.
124
+ *
125
+ * THE SAME REPLAY CAN ALSO ARRIVE WITHIN ONE STILL-RUNNING PROCESS, on a
126
+ * same-process re-mint (a real `lease_expired`, not merely a dropped
127
+ * connection): a fresh `runListener()` call starts a FRESH internal duplicate
128
+ * guard, seeded only from the `resumeIds` this call hands it — and a held id
129
+ * was, by the paragraph above, never in `delivered`. Left unhandled this
130
+ * would let the SAME held event be counted into TWO digests once flushed
131
+ * twice. `heldIds()` closes this: every `runListener()` call is seeded with
132
+ * `[...delivered, ...heldIds()]`, so the fresh listener's own guard
133
+ * (`listen.ts`'s `delivered` Set) already knows about a held id and silently
134
+ * drops a replay of it before it ever reaches `routeEvent` again —
135
+ * `routeEvent`'s own id check on `held` is the second, cheaper layer behind
136
+ * that, for defense in depth.
137
+ *
138
+ * A digest record rides the exact same `{type:"event", id, text}` shape a
139
+ * single event does (`id` is the highest id it combines, or `null` if every
140
+ * combined event carried none) — the plugin that relays bridge output
141
+ * (`plan-event-bridge.mjs`, outside this repo) already forwards any such
142
+ * record generically, so a digest needs no change on that side.
143
+ *
144
+ * A REAL TIMER, NOT `deps.sleep`. Unlike every other timing decision in this
145
+ * module (mint backoff, reconnect backoff), the digest interval is not tied to
146
+ * stream activity — it must fire even while the stream sits idle between
147
+ * events — so it is a plain `setTimeout`, mockable with fake timers in tests,
148
+ * rather than routed through the deliberately stream-driven `deps.sleep`.
149
+ */
150
+ export const DIGEST_INTERVAL_MS = 10 * 60_000;
151
+ const DIGEST_PREFIX = "[danx-dashboard bridge]";
152
+ /** ONE combined record for every event held since the last flush. */
153
+ function formatDigest(entries) {
154
+ const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
155
+ return [header, ...entries.map((e) => e.text)].join("\n");
156
+ }
61
157
  export const BRIDGE_SUBCOMMAND = "bridge";
62
158
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
63
- export const PLAN_MINE_PATH = "/api/plans/mine";
64
- export const ISSUES_PATH = "/api/issues";
65
159
  export const SESSION_ID_HEADER = "x-danx-session-id";
66
160
  /** How long one dashboard request may take before it counts as a transient failure. */
67
161
  export const REQUEST_TIMEOUT_MS = 10_000;
@@ -69,21 +163,27 @@ export const MINT_INITIAL_BACKOFF_MS = 1_000;
69
163
  export const MINT_MAX_BACKOFF_MS = 60_000;
70
164
  /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
71
165
  export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
72
- /** One page of the connected plan's cards, for the board-reach check. The server's ceiling. */
73
- export const PLAN_CARDS_PAGE_SIZE = 1_000;
74
166
  const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
75
- const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
76
167
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
77
168
  `[--resume-ids <event-id>[,<event-id>...]]`;
78
169
  /**
79
170
  * The remedy per terminal reason THIS module knows about. ONE map, so the
80
171
  * message a session is shown and the message a log carries can never drift.
81
172
  * `satisfies` makes a reason missing its remedy a compile error here — but
82
- * `reason` can also arrive at runtime from the dashboard's own SSE end event
83
- * (`ListenStopped.reason` is an open string, not this union), so a build that
84
- * predates a newer dashboard reason still needs an honest, generic fallback
85
- * rather than an index miss: that is `DEFAULT_STOP_FIX`, reached only for a
86
- * reason genuinely outside this list, never for one of the reasons above.
173
+ * `KnownStopReason` above is only a SUBSET of `ListenStopReason` (DX-2920
174
+ * round 4 already made `ListenStopped.reason` a closed union, not a plain
175
+ * `string` — see `listen.ts`), and `ListenStopReason` itself has an `"unknown"`
176
+ * member for exactly a newer dashboard's wire reason this build predates
177
+ * (normalized there, never passed through raw). So a build older than the
178
+ * dashboard it talks to can still reach a real `ListenStopReason` value this
179
+ * map has no entry for. `fixForStop` below is deliberately typed to accept a
180
+ * plain `string`, wider than `ListenStopReason`, purely as defense in depth
181
+ * for a caller reached some other way (and what lets the forward-compat test
182
+ * below exercise the fallback with an arbitrary string) — every value that can
183
+ * actually reach it through `ListenStopped.reason` is already a real,
184
+ * compile-time-checked member of the closed union. `DEFAULT_STOP_FIX` is the
185
+ * honest, generic fallback for a reason genuinely outside this list, never a
186
+ * silent index miss.
87
187
  */
88
188
  export const STOP_FIXES = {
89
189
  no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
@@ -95,7 +195,11 @@ export const STOP_FIXES = {
95
195
  mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
96
196
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
97
197
  board_unreadable: "scope this session's dashboard credential to read that board, or connect a plan whose cards all live on boards it can read",
98
- scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
198
+ // DX-2920 (round 3) — a `scope_narrowed` end whose `boards` array was
199
+ // missing or invalid (`listen.ts`'s parse-time check). Named separately
200
+ // from `scope_narrowed` itself, which always carries boards and never
201
+ // reaches this generic map at all.
202
+ bad_end_payload: "call plan_connect again to mint a new listener ticket for this session",
99
203
  // A newer ticket exists (superseded) or another connection is already using
100
204
  // this one (replaced) — THIS process is ending because the session is
101
205
  // already served elsewhere, not because anything is broken.
@@ -127,6 +231,11 @@ export function parseBridgeArgs(argv, env) {
127
231
  }
128
232
  /** A start this process cannot make, named the way the session will be told about it. */
129
233
  export class BridgeStartError extends Error {
234
+ // DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
235
+ // is always one of `no_connection_record` / `credential_unavailable` /
236
+ // `credential_mismatch`, never `scope_narrowed` (there is no stream yet), so
237
+ // this can be the closed reason type the eventual `write({type:"stopped",
238
+ // reason: start.reason, ...})` needs it to be.
130
239
  reason;
131
240
  fix;
132
241
  constructor(reason, detail, fix) {
@@ -175,15 +284,43 @@ export function resolveBridgeOptions(args, env, options = {}) {
175
284
  resumeIds: args.resumeIds,
176
285
  };
177
286
  }
178
- function errorCodeOf(body) {
179
- return typeof body === "object" && body !== null && typeof body.error === "string"
180
- ? body.error
181
- : null;
287
+ /**
288
+ * DX-2920 — `body.boards` off a `403 issuer_cannot_read_plan_boards` refusal
289
+ * (`unreadableBoardsRefusal`, dashboard `plan-scope.ts`), or `[]` when the body
290
+ * doesn't carry a readable list (defensive — never trust the wire shape blindly).
291
+ */
292
+ function boardsOf(body) {
293
+ if (typeof body !== "object" || body === null)
294
+ return [];
295
+ const boards = body.boards;
296
+ return Array.isArray(boards) ? boards.filter((b) => typeof b === "string") : [];
297
+ }
298
+ /**
299
+ * DX-2920 — the ONE `board_unreadable` detail-line builder, shared by the mint
300
+ * 403 classification and the stream admission's identical 403, so the two can
301
+ * never drift into two different ideas of how to phrase "these boards".
302
+ */
303
+ function boardUnreadableDetail(boards, fallback) {
304
+ return boards.length > 0
305
+ ? `this session's dashboard credential cannot read board(s) ${boards.join(", ")} on the connected plan`
306
+ : fallback;
307
+ }
308
+ /**
309
+ * DX-2920 (round 3) — `scope_narrowed`'s fix, built dynamically from the
310
+ * boards `listen.ts` parsed off the dashboard's own `end` payload (AC 30661:
311
+ * "naming the missing boards and how to widen scope"), replacing the static
312
+ * generic `STOP_FIXES` entry round 2 shipped before the server actually sent
313
+ * a board list.
314
+ */
315
+ function scopeNarrowedFix(boards) {
316
+ return boards.length > 0
317
+ ? `widen this session's dashboard credential to cover board(s) ${boards.join(", ")}, or connect a plan whose cards all live on boards it can read`
318
+ : "widen this session's dashboard credential to cover every board the connected plan's cards live on, or connect a plan whose cards all live on boards it can read";
182
319
  }
183
320
  /**
184
321
  * One authenticated request, with a timeout, classifying the failures that are
185
- * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by the
186
- * mint and the board-reach check so the two can never disagree about which
322
+ * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
323
+ * every request this module makes so they can never disagree about which
187
324
  * failures are worth retrying.
188
325
  */
189
326
  async function request(options, deps, what, url, init = { method: "GET" }) {
@@ -259,6 +396,19 @@ export async function mintTicket(options, deps) {
259
396
  if (status === 409 && errorCodeOf(body) === "session_not_connected") {
260
397
  return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
261
398
  }
399
+ // DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
400
+ // board the connected plan covers (`unreadableBoardsRefusal`, dashboard
401
+ // `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
402
+ // credential, which was the wrong remedy for a scope problem — this is
403
+ // `board_unreadable`, naming the boards, checked BEFORE the generic 401/403
404
+ // fallback below so it never falls into it.
405
+ if (status === 403 && errorCodeOf(body) === "issuer_cannot_read_plan_boards") {
406
+ return {
407
+ kind: "terminal",
408
+ reason: "board_unreadable",
409
+ detail: boardUnreadableDetail(boardsOf(body), `ticket mint HTTP 403: issuer_cannot_read_plan_boards ${oneLine(text, 300)}`),
410
+ };
411
+ }
262
412
  if (status === 401 || status === 403) {
263
413
  return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
264
414
  }
@@ -277,121 +427,145 @@ export async function mintTicket(options, deps) {
277
427
  leaseMs: ticket.leaseMs,
278
428
  };
279
429
  }
280
- /** One card per board of the connected plan, read a page at a time. */
281
- function cardsPageOf(body) {
282
- if (typeof body !== "object" || body === null)
283
- return "the body is not a JSON object";
284
- const b = body;
285
- if (!Array.isArray(b.cards))
286
- return "cards is not an array";
287
- if (typeof b.cards_total !== "number" || !Number.isSafeInteger(b.cards_total))
288
- return "cards_total is not an integer";
289
- const cards = [];
290
- for (const card of b.cards) {
291
- if (typeof card !== "object" || card === null)
292
- return "a card is not an object";
293
- const c = card;
294
- if (typeof c.id !== "string" || typeof c.boardId !== "string")
295
- return "a card has no id / boardId";
296
- cards.push({ id: c.id, boardId: c.boardId });
297
- }
298
- return { cards, total: b.cards_total };
299
- }
300
430
  /**
301
- * Prove this credential can read every board the connected plan's cards live on.
431
+ * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
432
+ * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
433
+ * shape matches one the mint route already gives its OWN name to. Two cases:
302
434
  *
303
- * WHY THIS EXISTS AT ALL. The stream admits an event only when the ticket
304
- * issuer's board allowlist covers the event's board and the issuer still holds
305
- * `read` (`isVisibleToStream` / `resolveIssuerScope`, dashboard). A credential
306
- * short of that streams exactly as happily as a correct one and simply never
307
- * relays those boards' events — silence that looks identical to "nothing has
308
- * happened yet".
435
+ * - `409 session_not_connected` — the SAME shape the mint route uses for "no
436
+ * plan connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A
437
+ * session with no plan connected is the identical terminal state whether
438
+ * the dashboard says so at mint or at the stream's own admission check,
439
+ * and the session should see ONE remedy either way.
440
+ * - `403 issuer_cannot_read_plan_boards` — the SAME shape the mint route's
441
+ * board-coverage refusal uses (AC 30660's fix 4 — a ticket minted before a
442
+ * board became unreadable can still be refused ADMISSION for it later; that
443
+ * refusal deserves the same `board_unreadable` naming as the mint-time one,
444
+ * not a re-mint-and-retry loop that just hits the identical refusal again).
309
445
  *
310
- * WHY THESE TWO ROUTES. `GET /api/plans/mine?fields=cards` is the only read that
311
- * names the connected plan's cards, and `GET /api/issues/:id/problems` is a card
312
- * read that runs the SAME board-allowlist + `read` check the stream does
313
- * (`withV2Handler` + `resolveIssueBoardId`). Deliberately NOT `GET /api/issues`
314
- * or `GET /api/issues/:id`: neither checks the caller's board allowlist at all,
315
- * so a check built on them would pass precisely where the stream drops events.
446
+ * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
447
+ * flattened `detail` string — the second-source-of-truth shape this card's fix
448
+ * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
316
449
  */
317
- export async function verifyPlanBoardsReadable(options, deps) {
318
- const firstCardPerBoard = new Map();
319
- let offset = 0;
320
- for (;;) {
321
- const url = `${options.dashboardUrl}${PLAN_MINE_PATH}?fields=cards&cards_limit=${PLAN_CARDS_PAGE_SIZE}&cards_offset=${offset}`;
322
- const outcome = await request(options, deps, "plan read", url);
323
- if (outcome.kind === "transient")
324
- return outcome;
325
- const { status, text, body } = outcome;
326
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
327
- return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
328
- }
329
- if (status === 401 || status === 403) {
330
- return { kind: "terminal", reason: "unauthorized", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
331
- }
332
- if (status < 200 || status >= 300) {
333
- return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
334
- }
335
- const page = cardsPageOf(body);
336
- if (typeof page === "string") {
337
- return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status}: ${page}` };
338
- }
339
- for (const card of page.cards) {
340
- if (!firstCardPerBoard.has(card.boardId))
341
- firstCardPerBoard.set(card.boardId, card.id);
342
- }
343
- offset += page.cards.length;
344
- if (page.cards.length === 0 || offset >= page.total)
345
- break;
450
+ function classifyAdmissionRefusal(refusal) {
451
+ if (refusal === undefined)
452
+ return null;
453
+ if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
454
+ return { reason: "not_connected", detail: "the session is not connected to a plan" };
346
455
  }
347
- for (const [boardId, cardId] of firstCardPerBoard) {
348
- // `?board=` is not read by this route's auth check (it derives the board from
349
- // `cardId`'s own `board_id` — `resolveIssueBoardId`) — kept only so the URL and
350
- // the `board read of ${boardId}` label above it name the same board at a glance.
351
- const url = `${options.dashboardUrl}${ISSUES_PATH}/${encodeURIComponent(cardId)}/problems?board=${encodeURIComponent(boardId)}`;
352
- const outcome = await request(options, deps, `board read of ${boardId}`, url);
353
- if (outcome.kind === "transient")
354
- return outcome;
355
- const { status, text } = outcome;
356
- if (status === 403) {
357
- return {
358
- kind: "terminal",
359
- reason: "board_unreadable",
360
- detail: `this session's dashboard credential cannot read board ${boardId}, which the connected plan's card ` +
361
- `${cardId} lives on — the event stream drops that board's events silently (${oneLine(text, 200)})`,
362
- };
363
- }
364
- if (status === 401) {
365
- return { kind: "terminal", reason: "unauthorized", detail: `board read of ${boardId} HTTP 401 ${oneLine(text, 200)}` };
366
- }
367
- if (status === 404) {
368
- // The card moved or was deleted between the two reads. Nothing is wrong
369
- // with the credential — re-read the plan rather than accuse it.
370
- return { kind: "transient", detail: `card ${cardId} vanished between the plan read and the board read` };
371
- }
372
- if (status < 200 || status >= 300) {
373
- return {
374
- kind: "terminal",
375
- reason: "scope_check_failed",
376
- detail: `board read of ${boardId} HTTP ${status} ${oneLine(text, 200)}`,
377
- };
378
- }
456
+ if (refusal.status === 403 && refusal.errorCode === "issuer_cannot_read_plan_boards") {
457
+ return {
458
+ reason: "board_unreadable",
459
+ detail: boardUnreadableDetail(boardsOf(refusal.body), "this session's dashboard credential cannot read a board on the connected plan"),
460
+ };
379
461
  }
380
- return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
462
+ return null;
381
463
  }
382
464
  /**
383
- * Mint, verify, stream, re-mint — until a terminal outcome, which it writes as
384
- * the one `stopped` record. Returns 0 when another listener took over, 1 otherwise.
465
+ * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
466
+ * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
385
467
  */
386
468
  export async function runBridge(options, deps) {
387
- const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
388
- const stop = (reason, detail, code) => {
389
- deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
469
+ const delivered = capResumeIds(options.resumeIds);
470
+ function stop(reason, detail, code, extra) {
471
+ // DX-2730 D4 — every terminal return goes through here, so this is the ONE
472
+ // place that cancels a pending digest timer. CANCELLED, never flushed: for
473
+ // `superseded`/`replaced` a newer process is already streaming this same
474
+ // session and will receive (and hold, and eventually flush) these same
475
+ // held events itself via the dashboard's replay — flushing here too would
476
+ // double-deliver the digest. For every other terminal reason, whatever is
477
+ // still held was never marked delivered (see `emitNow`), so a future
478
+ // process picks it up the same way — a live, un-fired `setTimeout` would
479
+ // otherwise also keep this process's event loop alive for up to
480
+ // `DIGEST_INTERVAL_MS` after it should have exited.
481
+ if (digestTimer !== null) {
482
+ clearTimeout(digestTimer);
483
+ digestTimer = null;
484
+ }
485
+ if (reason === "scope_narrowed") {
486
+ if (extra?.boards === undefined) {
487
+ throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
488
+ }
489
+ deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix: scopeNarrowedFix(extra.boards) });
490
+ return code;
491
+ }
492
+ deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix: extra?.fix ?? fixForStop(reason) });
390
493
  return code;
391
- };
494
+ }
392
495
  let backoff = MINT_INITIAL_BACKOFF_MS;
393
496
  let refusedInRow = 0;
394
- let verified = false;
497
+ let readyEmitted = false;
498
+ // DX-2730 D4 — digest state, declared OUTSIDE the mint/reconnect loop below
499
+ // so a re-mint (a lapsed lease, still the SAME process) never loses a
500
+ // pending hold — see the module docblock for the restart case, which the
501
+ // resume cursor handles instead.
502
+ let held = [];
503
+ let digestTimer = null;
504
+ function markDelivered(id) {
505
+ if (id === null)
506
+ return;
507
+ delivered.push(id);
508
+ if (delivered.length > DELIVERED_ID_MEMORY)
509
+ delivered.shift();
510
+ }
511
+ /** Write an event downstream at once, and mark its id delivered — never before now. */
512
+ function emitNow(output) {
513
+ markDelivered(output.id);
514
+ deps.write(output);
515
+ }
516
+ /** Combine every held event into ONE record and emit it, if there is anything to combine. */
517
+ function flushDigest() {
518
+ if (digestTimer !== null) {
519
+ clearTimeout(digestTimer);
520
+ digestTimer = null;
521
+ }
522
+ if (held.length === 0)
523
+ return;
524
+ const entries = held;
525
+ held = [];
526
+ const ids = entries.map((e) => e.id).filter((id) => id !== null);
527
+ emitNow({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), text: formatDigest(entries), origin: null });
528
+ }
529
+ /** Every id currently held, unflushed — also fed back into the next `runListener` call's
530
+ * `resumeIds` below, so a re-mint's fresh listener knows these were already seen and its
531
+ * OWN duplicate guard (`listen.ts`'s `delivered` Set) silently drops a replay of one,
532
+ * rather than it reaching here a second time. */
533
+ function heldIds() {
534
+ return held.map((e) => e.id).filter((id) => id !== null);
535
+ }
536
+ /**
537
+ * DX-2730 D4 — the ONE place that decides immediate vs. held-for-digest.
538
+ * `operator` and `null` (nudge / unreadable — see `ListenEvent.origin`'s
539
+ * docblock) flush any pending digest first (never let a digest arrive AFTER
540
+ * the operator event that should have superseded it) and then emit at once.
541
+ * `agent` / `machine` are appended to the hold, arming the interval timer
542
+ * only for the FIRST held event since the last flush — a later addition
543
+ * must never push the deadline out, or the "at most every 10 minutes"
544
+ * guarantee would erode into "10 minutes after the last event," which could
545
+ * never fire while events keep arriving.
546
+ *
547
+ * DEDUPED BY ID (defense in depth): a held event's id is not added to
548
+ * `delivered` until it is actually flushed (see `emitNow`), which is what
549
+ * lets a crashed-and-restarted PROCESS recover it via the dashboard's own
550
+ * replay. The same replay can also legitimately re-arrive WITHIN one still-
551
+ * running process — a re-mint after a real `lease_expired` starts a fresh
552
+ * `runListener` whose own duplicate guard is seeded from `resumeIds` alone,
553
+ * and a still-held id was never in `delivered` to seed it with. `resumeIds`
554
+ * below closes that for the ordinary case by handing the fresh listener
555
+ * every held id too; this check is the second, cheaper layer for anything
556
+ * that reaches here anyway — never trust a single guard to hold alone.
557
+ */
558
+ function routeEvent(output) {
559
+ if (output.origin === "agent" || output.origin === "machine") {
560
+ if (output.id !== null && heldIds().includes(output.id))
561
+ return;
562
+ held.push(output);
563
+ digestTimer ??= setTimeout(flushDigest, DIGEST_INTERVAL_MS);
564
+ return;
565
+ }
566
+ flushDigest();
567
+ emitNow(output);
568
+ }
395
569
  for (;;) {
396
570
  const minted = await mintTicket(options, deps);
397
571
  if (minted.kind === "terminal")
@@ -401,24 +575,41 @@ export async function runBridge(options, deps) {
401
575
  backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
402
576
  continue;
403
577
  }
404
- // The reach check runs on the FIRST ticket only: it is a property of the
405
- // credential and the plan, and a re-mint (a lapsed lease) changes neither.
406
- if (!verified) {
407
- const scope = await verifyPlanBoardsReadable(options, deps);
408
- if (scope.kind === "terminal")
409
- return stop(scope.reason, scope.detail, 1);
410
- if (scope.kind === "transient") {
411
- await deps.sleep(backoff);
412
- backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
413
- continue;
414
- }
415
- verified = true;
416
- deps.write({ type: "ready", boards: scope.boards });
578
+ // DX-2920 — `ready` fires once, on the FIRST successful mint, now that
579
+ // board reach is proven by the dashboard's own mint refusal rather than a
580
+ // client-side check run here (see the module docblock + the deleted
581
+ // `verifyPlanBoardsReadable`). A re-mint (a lapsed lease) never repeats it.
582
+ if (!readyEmitted) {
583
+ readyEmitted = true;
584
+ deps.write({ type: "ready" });
417
585
  }
418
586
  backoff = MINT_INITIAL_BACKOFF_MS;
419
587
  const run = { stopped: null, emitted: false };
420
588
  const startedAt = deps.now();
421
- await runListener({ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered] }, {
589
+ // DX-2829 — `runListener`'s own return value (0 for a benign takeover, 1
590
+ // otherwise) is intentionally discarded here, never reused as this
591
+ // function's exit code: `runBridge` computes ITS OWN exit code below from
592
+ // `stopped.reason` via the `stop()` helper above, because a bridge-level
593
+ // stop can happen for reasons `runListener` never sees at all (mint
594
+ // failures, the refused-ticket retry budget, `classifyAdmissionRefusal`'s
595
+ // reclassification) — `stop()` is the one place that has to decide the
596
+ // exit code for ALL of them, not just the stream-reader's own outcomes.
597
+ // Reusing `runListener`'s return value here would be redundant at best
598
+ // (the two happen to agree for `superseded`/`replaced`) and wrong at worst
599
+ // wherever a bridge-level reason diverges from the stream-level one it
600
+ // was built from.
601
+ await runListener(
602
+ // DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
603
+ // not-yet-flushed event was never added to `delivered` (see `emitNow`),
604
+ // so without this a fresh listener after a re-mint would not recognize
605
+ // a replay of it as already-seen and would hold (and eventually
606
+ // digest) it a second time. See `routeEvent`'s docblock. (If this
607
+ // combined list ever exceeded `DELIVERED_ID_MEMORY`, `runListener`'s own
608
+ // `slice(-DELIVERED_ID_MEMORY)` would evict the OLDEST entries first —
609
+ // i.e. `delivered`'s ids before `heldIds()`'s, which is the right order:
610
+ // a held id is always the more recent of the two and the one a near-
611
+ // term replay is actually likely to touch.)
612
+ { streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered, ...heldIds()] }, {
422
613
  ...deps,
423
614
  write: (output) => {
424
615
  if (output.type === "stopped") {
@@ -426,23 +617,34 @@ export async function runBridge(options, deps) {
426
617
  return;
427
618
  }
428
619
  run.emitted = true;
429
- if (output.id !== null) {
430
- delivered.push(output.id);
431
- if (delivered.length > DELIVERED_ID_MEMORY)
432
- delivered.shift();
433
- }
434
- deps.write(output);
620
+ routeEvent(output);
435
621
  },
436
622
  });
437
623
  const stopped = run.stopped;
438
624
  if (stopped === null)
439
625
  throw new Error("the stream reader ended without a stop record");
440
- if (TAKEOVER_REASONS.has(stopped.reason))
626
+ // DX-2920 (round 4) — direct equality, not `TAKEOVER_REASONS.has(...)`:
627
+ // `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
628
+ // for the compiler — leaving it a `Set` lookup here would keep every
629
+ // downstream branch's exhaustive narrowing (which is what makes the final
630
+ // fallthrough's `stop(stopped.reason, ...)` call type-check against the
631
+ // closed `ListenStopReason` overload) from working.
632
+ if (stopped.reason === "superseded" || stopped.reason === "replaced")
441
633
  return stop(stopped.reason, stopped.detail, 0);
442
634
  if (stopped.reason === "lease_expired") {
443
635
  refusedInRow = 0;
444
636
  continue;
445
637
  }
638
+ if (stopped.reason === "refused") {
639
+ const classified = classifyAdmissionRefusal(stopped.refusal);
640
+ if (classified !== null) {
641
+ // DX-2920 — a NAMED admission refusal is terminal at once, exactly
642
+ // like its mint-time counterpart — never counted toward the generic
643
+ // refused-ticket retry budget below, which exists for an admission
644
+ // refusal with no more specific explanation than "not admitted".
645
+ return stop(classified.reason, classified.detail, 1);
646
+ }
647
+ }
446
648
  if (stopped.reason === "refused") {
447
649
  const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
448
650
  refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
@@ -451,6 +653,15 @@ export async function runBridge(options, deps) {
451
653
  }
452
654
  continue;
453
655
  }
656
+ if (stopped.reason === "scope_narrowed") {
657
+ // DX-2920 (round 4) — `stopped.boards` is REQUIRED at the type level now
658
+ // (`ListenStopped`'s `scope_narrowed` member), populated by `listen.ts`'s
659
+ // parse of the dashboard's own `end` payload (never a re-derivation from
660
+ // `detail`) — no `?? []`, no optional check, `stopped.boards` IS
661
+ // `string[]` once narrowed to this branch.
662
+ const boards = stopped.boards;
663
+ return stop("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
664
+ }
454
665
  return stop(stopped.reason, stopped.detail, 1);
455
666
  }
456
667
  }