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