@thehammer/danx-dashboard-mcp 0.1.87 → 0.1.88
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 +147 -337
- package/dist/handlers.js +1 -85
- package/dist/index.js +13 -58
- package/dist/listen.js +21 -288
- package/package.json +1 -1
- package/dist/json-body.js +0 -23
- package/dist/plan-state.js +0 -194
package/README.md
CHANGED
|
@@ -60,10 +60,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
|
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
- **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANXBOT_DISPATCH_TOKEN` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
|
|
63
|
-
- **
|
|
63
|
+
- **Reach check.** After minting, `bridge` proves the credential can read every board the connected plan's cards live on (`GET /api/plans/mine?fields=cards`, then `GET /api/issues/:id/problems` for one card per board — the routes that run the same board-allowlist + `read` check the stream itself applies). A board it cannot read stops it with `board_unreadable` rather than streaming on and dropping that board's events. Then, once, `{"type":"ready","boards":[…]}`.
|
|
64
64
|
- **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
|
|
65
65
|
- **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
|
|
66
|
-
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected
|
|
66
|
+
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `board_unreadable` / `scope_check_failed` (the reach check above), `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
|
|
67
67
|
- **Reconnect and resume.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
|
|
68
68
|
|
|
69
69
|
## Build + test
|
package/dist/bridge.js
CHANGED
|
@@ -20,59 +20,29 @@
|
|
|
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
|
-
* issuer's board allowlist covers that
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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`.
|
|
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.
|
|
42
29
|
* 5. Streams through `runListener` (`listen.ts`), which reconnects within the
|
|
43
30
|
* ticket, and re-mints when that ticket's lease runs out or a fresh ticket
|
|
44
31
|
* is refused once.
|
|
45
|
-
* 6. Writes JSON Lines to stdout: ONE `{type:"ready"}` once
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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.
|
|
48
35
|
*
|
|
49
36
|
* TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
|
|
50
37
|
* - `no_connection_record` this session has no connection record to read;
|
|
51
38
|
* - `credential_unavailable` its declared credential cannot be resolved here;
|
|
52
39
|
* - `credential_mismatch` what resolved is not the server's credential;
|
|
53
|
-
* - `not_connected` the mint
|
|
54
|
-
*
|
|
55
|
-
* at admission; `end("not_connected")` once live);
|
|
56
|
-
* - `unauthorized` the mint answered 401 / 403 for a reason OTHER
|
|
57
|
-
* than an unreadable board;
|
|
40
|
+
* - `not_connected` the mint answered 409 `session_not_connected`;
|
|
41
|
+
* - `unauthorized` the mint or a scope read answered 401 / 403;
|
|
58
42
|
* - `mint_refused` any other non-transient mint refusal (400, 404, …);
|
|
59
43
|
* - `mint_bad_response` a 2xx mint whose body is not a ticket;
|
|
60
|
-
* - `board_unreadable` the
|
|
61
|
-
*
|
|
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);
|
|
44
|
+
* - `board_unreadable` the credential cannot read a board of this plan;
|
|
45
|
+
* - `scope_check_failed` the plan's boards could not be established;
|
|
76
46
|
* - `superseded` / `replaced` (exit 0) — another listener now holds the session;
|
|
77
47
|
* - `revoked` the dashboard revoked the ticket or its issuer;
|
|
78
48
|
* - `refused` freshly minted tickets were refused twice in a row.
|
|
@@ -84,78 +54,14 @@
|
|
|
84
54
|
* session's own MCP server declared, resolved here from that declaration.
|
|
85
55
|
*/
|
|
86
56
|
import { homedir } from "node:os";
|
|
87
|
-
import { errorCodeOf } from "./json-body.js";
|
|
88
57
|
import { oneLine } from "./one-line.js";
|
|
89
58
|
import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
|
|
90
59
|
import { readSessionConnection } from "./session-connection.js";
|
|
91
60
|
import { 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
|
-
}
|
|
157
61
|
export const BRIDGE_SUBCOMMAND = "bridge";
|
|
158
62
|
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";
|
|
159
65
|
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
160
66
|
/** How long one dashboard request may take before it counts as a transient failure. */
|
|
161
67
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -163,7 +69,10 @@ export const MINT_INITIAL_BACKOFF_MS = 1_000;
|
|
|
163
69
|
export const MINT_MAX_BACKOFF_MS = 60_000;
|
|
164
70
|
/** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
|
|
165
71
|
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;
|
|
166
74
|
const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
75
|
+
const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
|
|
167
76
|
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
|
|
168
77
|
`[--resume-ids <event-id>[,<event-id>...]]`;
|
|
169
78
|
/**
|
|
@@ -186,11 +95,7 @@ export const STOP_FIXES = {
|
|
|
186
95
|
mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
|
|
187
96
|
mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
|
|
188
97
|
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",
|
|
189
|
-
|
|
190
|
-
// missing or invalid (`listen.ts`'s parse-time check). Named separately
|
|
191
|
-
// from `scope_narrowed` itself, which always carries boards and never
|
|
192
|
-
// reaches this generic map at all.
|
|
193
|
-
bad_end_payload: "call plan_connect again to mint a new listener ticket for this session",
|
|
98
|
+
scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
|
|
194
99
|
// A newer ticket exists (superseded) or another connection is already using
|
|
195
100
|
// this one (replaced) — THIS process is ending because the session is
|
|
196
101
|
// already served elsewhere, not because anything is broken.
|
|
@@ -222,11 +127,6 @@ export function parseBridgeArgs(argv, env) {
|
|
|
222
127
|
}
|
|
223
128
|
/** A start this process cannot make, named the way the session will be told about it. */
|
|
224
129
|
export class BridgeStartError extends Error {
|
|
225
|
-
// DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
|
|
226
|
-
// is always one of `no_connection_record` / `credential_unavailable` /
|
|
227
|
-
// `credential_mismatch`, never `scope_narrowed` (there is no stream yet), so
|
|
228
|
-
// this can be the closed reason type the eventual `write({type:"stopped",
|
|
229
|
-
// reason: start.reason, ...})` needs it to be.
|
|
230
130
|
reason;
|
|
231
131
|
fix;
|
|
232
132
|
constructor(reason, detail, fix) {
|
|
@@ -275,43 +175,15 @@ export function resolveBridgeOptions(args, env, options = {}) {
|
|
|
275
175
|
resumeIds: args.resumeIds,
|
|
276
176
|
};
|
|
277
177
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
*/
|
|
283
|
-
function boardsOf(body) {
|
|
284
|
-
if (typeof body !== "object" || body === null)
|
|
285
|
-
return [];
|
|
286
|
-
const boards = body.boards;
|
|
287
|
-
return Array.isArray(boards) ? boards.filter((b) => typeof b === "string") : [];
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* DX-2920 — the ONE `board_unreadable` detail-line builder, shared by the mint
|
|
291
|
-
* 403 classification and the stream admission's identical 403, so the two can
|
|
292
|
-
* never drift into two different ideas of how to phrase "these boards".
|
|
293
|
-
*/
|
|
294
|
-
function boardUnreadableDetail(boards, fallback) {
|
|
295
|
-
return boards.length > 0
|
|
296
|
-
? `this session's dashboard credential cannot read board(s) ${boards.join(", ")} on the connected plan`
|
|
297
|
-
: fallback;
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* DX-2920 (round 3) — `scope_narrowed`'s fix, built dynamically from the
|
|
301
|
-
* boards `listen.ts` parsed off the dashboard's own `end` payload (AC 30661:
|
|
302
|
-
* "naming the missing boards and how to widen scope"), replacing the static
|
|
303
|
-
* generic `STOP_FIXES` entry round 2 shipped before the server actually sent
|
|
304
|
-
* a board list.
|
|
305
|
-
*/
|
|
306
|
-
function scopeNarrowedFix(boards) {
|
|
307
|
-
return boards.length > 0
|
|
308
|
-
? `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`
|
|
309
|
-
: "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";
|
|
178
|
+
function errorCodeOf(body) {
|
|
179
|
+
return typeof body === "object" && body !== null && typeof body.error === "string"
|
|
180
|
+
? body.error
|
|
181
|
+
: null;
|
|
310
182
|
}
|
|
311
183
|
/**
|
|
312
184
|
* One authenticated request, with a timeout, classifying the failures that are
|
|
313
|
-
* about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
|
|
314
|
-
*
|
|
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
|
|
315
187
|
* failures are worth retrying.
|
|
316
188
|
*/
|
|
317
189
|
async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
@@ -387,19 +259,6 @@ export async function mintTicket(options, deps) {
|
|
|
387
259
|
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
388
260
|
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
389
261
|
}
|
|
390
|
-
// DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
|
|
391
|
-
// board the connected plan covers (`unreadableBoardsRefusal`, dashboard
|
|
392
|
-
// `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
|
|
393
|
-
// credential, which was the wrong remedy for a scope problem — this is
|
|
394
|
-
// `board_unreadable`, naming the boards, checked BEFORE the generic 401/403
|
|
395
|
-
// fallback below so it never falls into it.
|
|
396
|
-
if (status === 403 && errorCodeOf(body) === "issuer_cannot_read_plan_boards") {
|
|
397
|
-
return {
|
|
398
|
-
kind: "terminal",
|
|
399
|
-
reason: "board_unreadable",
|
|
400
|
-
detail: boardUnreadableDetail(boardsOf(body), `ticket mint HTTP 403: issuer_cannot_read_plan_boards ${oneLine(text, 300)}`),
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
262
|
if (status === 401 || status === 403) {
|
|
404
263
|
return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
|
|
405
264
|
}
|
|
@@ -418,145 +277,121 @@ export async function mintTicket(options, deps) {
|
|
|
418
277
|
leaseMs: ticket.leaseMs,
|
|
419
278
|
};
|
|
420
279
|
}
|
|
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
|
+
}
|
|
421
300
|
/**
|
|
422
|
-
*
|
|
423
|
-
* outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
|
|
424
|
-
* shape matches one the mint route already gives its OWN name to. Two cases:
|
|
301
|
+
* Prove this credential can read every board the connected plan's cards live on.
|
|
425
302
|
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* board-coverage refusal uses (AC 30660's fix 4 — a ticket minted before a
|
|
433
|
-
* board became unreadable can still be refused ADMISSION for it later; that
|
|
434
|
-
* refusal deserves the same `board_unreadable` naming as the mint-time one,
|
|
435
|
-
* not a re-mint-and-retry loop that just hits the identical refusal again).
|
|
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".
|
|
436
309
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
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.
|
|
440
316
|
*/
|
|
441
|
-
function
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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;
|
|
446
346
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
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
|
+
}
|
|
452
379
|
}
|
|
453
|
-
return
|
|
380
|
+
return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
|
|
454
381
|
}
|
|
455
382
|
/**
|
|
456
|
-
* Mint, stream, re-mint — until a terminal outcome, which it writes as
|
|
457
|
-
* `stopped` record. Returns 0 when another listener took over, 1 otherwise.
|
|
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.
|
|
458
385
|
*/
|
|
459
386
|
export async function runBridge(options, deps) {
|
|
460
387
|
const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
// place that cancels a pending digest timer. CANCELLED, never flushed: for
|
|
464
|
-
// `superseded`/`replaced` a newer process is already streaming this same
|
|
465
|
-
// session and will receive (and hold, and eventually flush) these same
|
|
466
|
-
// held events itself via the dashboard's replay — flushing here too would
|
|
467
|
-
// double-deliver the digest. For every other terminal reason, whatever is
|
|
468
|
-
// still held was never marked delivered (see `emitNow`), so a future
|
|
469
|
-
// process picks it up the same way — a live, un-fired `setTimeout` would
|
|
470
|
-
// otherwise also keep this process's event loop alive for up to
|
|
471
|
-
// `DIGEST_INTERVAL_MS` after it should have exited.
|
|
472
|
-
if (digestTimer !== null) {
|
|
473
|
-
clearTimeout(digestTimer);
|
|
474
|
-
digestTimer = null;
|
|
475
|
-
}
|
|
476
|
-
if (reason === "scope_narrowed") {
|
|
477
|
-
if (extra?.boards === undefined) {
|
|
478
|
-
throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
|
|
479
|
-
}
|
|
480
|
-
deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix: scopeNarrowedFix(extra.boards) });
|
|
481
|
-
return code;
|
|
482
|
-
}
|
|
483
|
-
deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix: extra?.fix ?? fixForStop(reason) });
|
|
388
|
+
const stop = (reason, detail, code) => {
|
|
389
|
+
deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
|
|
484
390
|
return code;
|
|
485
|
-
}
|
|
391
|
+
};
|
|
486
392
|
let backoff = MINT_INITIAL_BACKOFF_MS;
|
|
487
393
|
let refusedInRow = 0;
|
|
488
|
-
let
|
|
489
|
-
// DX-2730 D4 — digest state, declared OUTSIDE the mint/reconnect loop below
|
|
490
|
-
// so a re-mint (a lapsed lease, still the SAME process) never loses a
|
|
491
|
-
// pending hold — see the module docblock for the restart case, which the
|
|
492
|
-
// resume cursor handles instead.
|
|
493
|
-
let held = [];
|
|
494
|
-
let digestTimer = null;
|
|
495
|
-
function markDelivered(id) {
|
|
496
|
-
if (id === null)
|
|
497
|
-
return;
|
|
498
|
-
delivered.push(id);
|
|
499
|
-
if (delivered.length > DELIVERED_ID_MEMORY)
|
|
500
|
-
delivered.shift();
|
|
501
|
-
}
|
|
502
|
-
/** Write an event downstream at once, and mark its id delivered — never before now. */
|
|
503
|
-
function emitNow(output) {
|
|
504
|
-
markDelivered(output.id);
|
|
505
|
-
deps.write(output);
|
|
506
|
-
}
|
|
507
|
-
/** Combine every held event into ONE record and emit it, if there is anything to combine. */
|
|
508
|
-
function flushDigest() {
|
|
509
|
-
if (digestTimer !== null) {
|
|
510
|
-
clearTimeout(digestTimer);
|
|
511
|
-
digestTimer = null;
|
|
512
|
-
}
|
|
513
|
-
if (held.length === 0)
|
|
514
|
-
return;
|
|
515
|
-
const entries = held;
|
|
516
|
-
held = [];
|
|
517
|
-
const ids = entries.map((e) => e.id).filter((id) => id !== null);
|
|
518
|
-
emitNow({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), text: formatDigest(entries), origin: null });
|
|
519
|
-
}
|
|
520
|
-
/** Every id currently held, unflushed — also fed back into the next `runListener` call's
|
|
521
|
-
* `resumeIds` below, so a re-mint's fresh listener knows these were already seen and its
|
|
522
|
-
* OWN duplicate guard (`listen.ts`'s `delivered` Set) silently drops a replay of one,
|
|
523
|
-
* rather than it reaching here a second time. */
|
|
524
|
-
function heldIds() {
|
|
525
|
-
return held.map((e) => e.id).filter((id) => id !== null);
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* DX-2730 D4 — the ONE place that decides immediate vs. held-for-digest.
|
|
529
|
-
* `operator` and `null` (nudge / unreadable — see `ListenEvent.origin`'s
|
|
530
|
-
* docblock) flush any pending digest first (never let a digest arrive AFTER
|
|
531
|
-
* the operator event that should have superseded it) and then emit at once.
|
|
532
|
-
* `agent` / `machine` are appended to the hold, arming the interval timer
|
|
533
|
-
* only for the FIRST held event since the last flush — a later addition
|
|
534
|
-
* must never push the deadline out, or the "at most every 10 minutes"
|
|
535
|
-
* guarantee would erode into "10 minutes after the last event," which could
|
|
536
|
-
* never fire while events keep arriving.
|
|
537
|
-
*
|
|
538
|
-
* DEDUPED BY ID (defense in depth): a held event's id is not added to
|
|
539
|
-
* `delivered` until it is actually flushed (see `emitNow`), which is what
|
|
540
|
-
* lets a crashed-and-restarted PROCESS recover it via the dashboard's own
|
|
541
|
-
* replay. The same replay can also legitimately re-arrive WITHIN one still-
|
|
542
|
-
* running process — a re-mint after a real `lease_expired` starts a fresh
|
|
543
|
-
* `runListener` whose own duplicate guard is seeded from `resumeIds` alone,
|
|
544
|
-
* and a still-held id was never in `delivered` to seed it with. `resumeIds`
|
|
545
|
-
* below closes that for the ordinary case by handing the fresh listener
|
|
546
|
-
* every held id too; this check is the second, cheaper layer for anything
|
|
547
|
-
* that reaches here anyway — never trust a single guard to hold alone.
|
|
548
|
-
*/
|
|
549
|
-
function routeEvent(output) {
|
|
550
|
-
if (output.origin === "agent" || output.origin === "machine") {
|
|
551
|
-
if (output.id !== null && heldIds().includes(output.id))
|
|
552
|
-
return;
|
|
553
|
-
held.push(output);
|
|
554
|
-
digestTimer ??= setTimeout(flushDigest, DIGEST_INTERVAL_MS);
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
flushDigest();
|
|
558
|
-
emitNow(output);
|
|
559
|
-
}
|
|
394
|
+
let verified = false;
|
|
560
395
|
for (;;) {
|
|
561
396
|
const minted = await mintTicket(options, deps);
|
|
562
397
|
if (minted.kind === "terminal")
|
|
@@ -566,29 +401,24 @@ export async function runBridge(options, deps) {
|
|
|
566
401
|
backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
|
|
567
402
|
continue;
|
|
568
403
|
}
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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 });
|
|
576
417
|
}
|
|
577
418
|
backoff = MINT_INITIAL_BACKOFF_MS;
|
|
578
419
|
const run = { stopped: null, emitted: false };
|
|
579
420
|
const startedAt = deps.now();
|
|
580
|
-
await runListener(
|
|
581
|
-
// DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
|
|
582
|
-
// not-yet-flushed event was never added to `delivered` (see `emitNow`),
|
|
583
|
-
// so without this a fresh listener after a re-mint would not recognize
|
|
584
|
-
// a replay of it as already-seen and would hold (and eventually
|
|
585
|
-
// digest) it a second time. See `routeEvent`'s docblock. (If this
|
|
586
|
-
// combined list ever exceeded `DELIVERED_ID_MEMORY`, `runListener`'s own
|
|
587
|
-
// `slice(-DELIVERED_ID_MEMORY)` would evict the OLDEST entries first —
|
|
588
|
-
// i.e. `delivered`'s ids before `heldIds()`'s, which is the right order:
|
|
589
|
-
// a held id is always the more recent of the two and the one a near-
|
|
590
|
-
// term replay is actually likely to touch.)
|
|
591
|
-
{ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered, ...heldIds()] }, {
|
|
421
|
+
await runListener({ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered] }, {
|
|
592
422
|
...deps,
|
|
593
423
|
write: (output) => {
|
|
594
424
|
if (output.type === "stopped") {
|
|
@@ -596,34 +426,23 @@ export async function runBridge(options, deps) {
|
|
|
596
426
|
return;
|
|
597
427
|
}
|
|
598
428
|
run.emitted = true;
|
|
599
|
-
|
|
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);
|
|
600
435
|
},
|
|
601
436
|
});
|
|
602
437
|
const stopped = run.stopped;
|
|
603
438
|
if (stopped === null)
|
|
604
439
|
throw new Error("the stream reader ended without a stop record");
|
|
605
|
-
|
|
606
|
-
// `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
|
|
607
|
-
// for the compiler — leaving it a `Set` lookup here would keep every
|
|
608
|
-
// downstream branch's exhaustive narrowing (which is what makes the final
|
|
609
|
-
// fallthrough's `stop(stopped.reason, ...)` call type-check against the
|
|
610
|
-
// closed `ListenStopReason` overload) from working.
|
|
611
|
-
if (stopped.reason === "superseded" || stopped.reason === "replaced")
|
|
440
|
+
if (TAKEOVER_REASONS.has(stopped.reason))
|
|
612
441
|
return stop(stopped.reason, stopped.detail, 0);
|
|
613
442
|
if (stopped.reason === "lease_expired") {
|
|
614
443
|
refusedInRow = 0;
|
|
615
444
|
continue;
|
|
616
445
|
}
|
|
617
|
-
if (stopped.reason === "refused") {
|
|
618
|
-
const classified = classifyAdmissionRefusal(stopped.refusal);
|
|
619
|
-
if (classified !== null) {
|
|
620
|
-
// DX-2920 — a NAMED admission refusal is terminal at once, exactly
|
|
621
|
-
// like its mint-time counterpart — never counted toward the generic
|
|
622
|
-
// refused-ticket retry budget below, which exists for an admission
|
|
623
|
-
// refusal with no more specific explanation than "not admitted".
|
|
624
|
-
return stop(classified.reason, classified.detail, 1);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
446
|
if (stopped.reason === "refused") {
|
|
628
447
|
const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
|
|
629
448
|
refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
|
|
@@ -632,15 +451,6 @@ export async function runBridge(options, deps) {
|
|
|
632
451
|
}
|
|
633
452
|
continue;
|
|
634
453
|
}
|
|
635
|
-
if (stopped.reason === "scope_narrowed") {
|
|
636
|
-
// DX-2920 (round 4) — `stopped.boards` is REQUIRED at the type level now
|
|
637
|
-
// (`ListenStopped`'s `scope_narrowed` member), populated by `listen.ts`'s
|
|
638
|
-
// parse of the dashboard's own `end` payload (never a re-derivation from
|
|
639
|
-
// `detail`) — no `?? []`, no optional check, `stopped.boards` IS
|
|
640
|
-
// `string[]` once narrowed to this branch.
|
|
641
|
-
const boards = stopped.boards;
|
|
642
|
-
return stop("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
|
|
643
|
-
}
|
|
644
454
|
return stop(stopped.reason, stopped.detail, 1);
|
|
645
455
|
}
|
|
646
456
|
}
|