@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/README.md +2 -2
- package/dist/bridge.js +365 -154
- package/dist/handlers.js +94 -2
- package/dist/index.js +66 -16
- package/dist/json-body.js +23 -0
- package/dist/listen.js +324 -27
- package/dist/one-line.js +3 -2
- package/dist/plan-state.js +194 -0
- package/package.json +2 -1
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.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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"
|
|
33
|
-
* is
|
|
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
|
|
41
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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
|
-
* `
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
186
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
462
|
+
return null;
|
|
381
463
|
}
|
|
382
464
|
/**
|
|
383
|
-
* Mint,
|
|
384
|
-
*
|
|
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
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|