@thehammer/danx-dashboard-mcp 0.1.93 → 0.1.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge.js +241 -34
- package/dist/listen.js +3 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -89,11 +89,12 @@
|
|
|
89
89
|
* is the only argument and holds no secret; the credential is whatever this
|
|
90
90
|
* session's own MCP server declared, resolved here from that declaration.
|
|
91
91
|
*/
|
|
92
|
+
import { randomUUID } from "node:crypto";
|
|
92
93
|
import { homedir } from "node:os";
|
|
93
94
|
import { errorCodeOf } from "./json-body.js";
|
|
94
95
|
import { oneLine } from "./one-line.js";
|
|
95
96
|
import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
|
|
96
|
-
import { readSessionConnection } from "./session-connection.js";
|
|
97
|
+
import { readSessionConnection, sessionConnectionPath } from "./session-connection.js";
|
|
97
98
|
import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
|
|
98
99
|
/**
|
|
99
100
|
* DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
|
|
@@ -169,12 +170,20 @@ export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
|
|
|
169
170
|
// streaming, and is best-effort (one attempt, no retry/backoff).
|
|
170
171
|
export const PLAN_MINE_PATH = "/api/plans/mine";
|
|
171
172
|
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
173
|
+
// DX-3028 (AC1) — `POST /api/plan-sessions/me/heartbeat` (DX-2952's server
|
|
174
|
+
// route, `plan-sessions-routes.ts#handleHeartbeat`). Every 15s this process
|
|
175
|
+
// reports its truthful streaming state; see `sendHeartbeat` below.
|
|
176
|
+
export const HEARTBEAT_PATH = "/api/plan-sessions/me/heartbeat";
|
|
172
177
|
/** How long one dashboard request may take before it counts as a transient failure. */
|
|
173
178
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
174
179
|
export const MINT_INITIAL_BACKOFF_MS = 1_000;
|
|
175
180
|
export const MINT_MAX_BACKOFF_MS = 60_000;
|
|
176
181
|
/** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
|
|
177
182
|
export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
|
|
183
|
+
/** DX-3028 (AC1) — the dashboard's own heartbeat cadence; see the card's AC1. */
|
|
184
|
+
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
185
|
+
/** DX-3028 (AC2) — the server-fixable degraded reasons' own periodic retry cadence. */
|
|
186
|
+
export const DEGRADED_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
178
187
|
const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
179
188
|
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
|
|
180
189
|
`[--resume-ids <event-id>[,<event-id>...]]`;
|
|
@@ -219,6 +228,9 @@ export const STOP_FIXES = {
|
|
|
219
228
|
replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
|
|
220
229
|
revoked: "call plan_connect again to mint a new listener ticket for this session",
|
|
221
230
|
refused: "call plan_connect again to mint a new listener ticket for this session",
|
|
231
|
+
// DX-3028 — this session id belongs to a worker dispatch, not an operator
|
|
232
|
+
// plan session; there is no listener ticket a worker session can ever use.
|
|
233
|
+
session_is_worker: "this session id belongs to a worker dispatch; start a new session",
|
|
222
234
|
};
|
|
223
235
|
export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the bridge";
|
|
224
236
|
export function fixForStop(reason) {
|
|
@@ -250,11 +262,17 @@ export class BridgeStartError extends Error {
|
|
|
250
262
|
// reason: start.reason, ...})` needs it to be.
|
|
251
263
|
reason;
|
|
252
264
|
fix;
|
|
253
|
-
|
|
265
|
+
/** DX-3028 — see `BridgeOptions.instanceId`. Always populated: minted before any of the three start-time checks run. */
|
|
266
|
+
instanceId;
|
|
267
|
+
/** DX-3028 — see `BridgeOptions.watchedPaths`. Empty for `no_connection_record` (there is nothing to watch yet). */
|
|
268
|
+
paths;
|
|
269
|
+
constructor(reason, detail, fix, instanceId, paths) {
|
|
254
270
|
super(detail);
|
|
255
271
|
this.name = "BridgeStartError";
|
|
256
272
|
this.reason = reason;
|
|
257
273
|
this.fix = fix;
|
|
274
|
+
this.instanceId = instanceId;
|
|
275
|
+
this.paths = paths;
|
|
258
276
|
}
|
|
259
277
|
}
|
|
260
278
|
/**
|
|
@@ -262,18 +280,48 @@ export class BridgeStartError extends Error {
|
|
|
262
280
|
* that this process holds the SAME credential that server does. Never falls back
|
|
263
281
|
* to an ambient `DANXBOT_DISPATCH_TOKEN`: that fallback is precisely DX-2862.
|
|
264
282
|
*/
|
|
283
|
+
const INSTANCE_ID_ENV = "DANX_BRIDGE_INSTANCE_ID";
|
|
284
|
+
/**
|
|
285
|
+
* DX-3028 — this process's own instance id: read from `DANX_BRIDGE_INSTANCE_ID`
|
|
286
|
+
* (minted by the plugin's `start()` before it spawns the run process, and
|
|
287
|
+
* carried down to this subcommand's env unchanged), or freshly minted for a
|
|
288
|
+
* manual/hand run that has no plugin supplying one. Every stop record and
|
|
289
|
+
* every heartbeat names this same id for the life of the process.
|
|
290
|
+
*/
|
|
291
|
+
function resolveInstanceId(env) {
|
|
292
|
+
const declared = env[INSTANCE_ID_ENV];
|
|
293
|
+
return typeof declared === "string" && declared.trim() !== "" ? declared.trim() : randomUUID();
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* DX-3028 — the on-disk paths this bridge's authentication depends on, for
|
|
297
|
+
* `BridgeOptions.watchedPaths` (see its docblock). A `file`-sourced
|
|
298
|
+
* credential adds its own path; an `env`-sourced one has no file
|
|
299
|
+
* (`credential.ts` ~47-48, ~90-95).
|
|
300
|
+
*/
|
|
301
|
+
function watchedPathsFor(record, home) {
|
|
302
|
+
const paths = [sessionConnectionPath(record.sessionId, home)];
|
|
303
|
+
if (record.source.kind === "file")
|
|
304
|
+
paths.push(record.source.path);
|
|
305
|
+
return paths;
|
|
306
|
+
}
|
|
265
307
|
export function resolveBridgeOptions(args, env, options = {}) {
|
|
266
308
|
const home = options.home ?? homedir();
|
|
309
|
+
const instanceId = resolveInstanceId(env);
|
|
267
310
|
let record;
|
|
268
311
|
try {
|
|
269
312
|
record = readSessionConnection(args.sessionId, { home });
|
|
270
313
|
}
|
|
271
314
|
catch (err) {
|
|
272
|
-
throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
|
|
315
|
+
throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"), instanceId, []);
|
|
273
316
|
}
|
|
274
317
|
if (record === null) {
|
|
275
|
-
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"));
|
|
318
|
+
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, []);
|
|
276
319
|
}
|
|
320
|
+
// DX-3028 — computed as soon as a connection record exists, even if the
|
|
321
|
+
// credential itself later fails to resolve: `credential_unavailable`'s own
|
|
322
|
+
// stop record still needs `paths` (the connection record path, at least)
|
|
323
|
+
// so the plugin has something to watch for a retry.
|
|
324
|
+
const watchedPaths = watchedPathsFor(record, home);
|
|
277
325
|
let token;
|
|
278
326
|
try {
|
|
279
327
|
token = readCredential(record.source, env);
|
|
@@ -282,18 +330,20 @@ export function resolveBridgeOptions(args, env, options = {}) {
|
|
|
282
330
|
const credentialError = err;
|
|
283
331
|
throw new BridgeStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`,
|
|
284
332
|
// CredentialError.fix is a required string, never absent — no fallback needed.
|
|
285
|
-
credentialError.fix);
|
|
333
|
+
credentialError.fix, instanceId, watchedPaths);
|
|
286
334
|
}
|
|
287
335
|
const fingerprint = credentialFingerprint(token);
|
|
288
336
|
if (fingerprint !== record.fingerprint) {
|
|
289
337
|
throw new BridgeStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
|
|
290
|
-
`danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"));
|
|
338
|
+
`danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"), instanceId, watchedPaths);
|
|
291
339
|
}
|
|
292
340
|
return {
|
|
293
341
|
dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""),
|
|
294
342
|
token,
|
|
295
343
|
sessionId: args.sessionId,
|
|
296
344
|
resumeIds: args.resumeIds,
|
|
345
|
+
instanceId,
|
|
346
|
+
watchedPaths,
|
|
297
347
|
};
|
|
298
348
|
}
|
|
299
349
|
/**
|
|
@@ -408,6 +458,13 @@ export async function mintTicket(options, deps) {
|
|
|
408
458
|
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
409
459
|
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
410
460
|
}
|
|
461
|
+
// DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
|
|
462
|
+
// which every other 409 falls into: this session id belongs to a worker
|
|
463
|
+
// dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
|
|
464
|
+
// already gives it, DX-2952).
|
|
465
|
+
if (status === 409 && errorCodeOf(body) === "session_is_worker") {
|
|
466
|
+
return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
|
|
467
|
+
}
|
|
411
468
|
// DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
|
|
412
469
|
// board the connected plan covers (`unreadableBoardsRefusal`, dashboard
|
|
413
470
|
// `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
|
|
@@ -511,36 +568,133 @@ function classifyAdmissionRefusal(refusal) {
|
|
|
511
568
|
}
|
|
512
569
|
return null;
|
|
513
570
|
}
|
|
571
|
+
const EXIT_NEVER_RESTART = new Set([
|
|
572
|
+
"no_connection_record",
|
|
573
|
+
"not_connected",
|
|
574
|
+
"superseded",
|
|
575
|
+
"replaced",
|
|
576
|
+
"session_is_worker",
|
|
577
|
+
]);
|
|
578
|
+
const DEGRADE_NO_HEARTBEAT = new Set(["credential_unavailable", "credential_mismatch"]);
|
|
579
|
+
export function degradePolicyFor(reason) {
|
|
580
|
+
if (EXIT_NEVER_RESTART.has(reason))
|
|
581
|
+
return "exit";
|
|
582
|
+
if (DEGRADE_NO_HEARTBEAT.has(reason))
|
|
583
|
+
return "degrade-no-heartbeat";
|
|
584
|
+
return "degrade-server-fixable";
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* One heartbeat POST. NEVER throws and NEVER stops the bridge on failure
|
|
588
|
+
* (card AC1 — "a failed heartbeat never stops the bridge"): `request()`
|
|
589
|
+
* itself already classifies every network/timeout/5xx failure as
|
|
590
|
+
* `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
|
|
591
|
+
* credential) simply comes back as an ordinary non-2xx answer this function
|
|
592
|
+
* discards — there is nothing meaningful to retry a heartbeat POST for, and
|
|
593
|
+
* the NEXT tick will just try again. A heartbeat refused with 401/403 cannot
|
|
594
|
+
* report itself, so `computeListenerHealth`'s staleness fallback
|
|
595
|
+
* (`heartbeatIsFresh`) is what eventually reflects it: the row's
|
|
596
|
+
* `last_heartbeat_at` stops advancing, and once it falls outside
|
|
597
|
+
* `SESSION_LIVENESS_WINDOW_MS` the badge reads `unattached`, NOT
|
|
598
|
+
* `credential_mismatch` — `credential_mismatch` is computed only from a
|
|
599
|
+
* heartbeat that DID land with `state:"streaming"` and a live ticket whose
|
|
600
|
+
* issuer hash differs from the caller's; it has no way to represent "the
|
|
601
|
+
* heartbeat call itself was rejected". This gap is real and is recorded on
|
|
602
|
+
* the card rather than silently papered over.
|
|
603
|
+
*/
|
|
604
|
+
async function sendHeartbeat(options, deps, connection) {
|
|
605
|
+
const body = { state: connection.state };
|
|
606
|
+
if (connection.reason !== undefined)
|
|
607
|
+
body.reason = connection.reason;
|
|
608
|
+
if (connection.inventory !== undefined)
|
|
609
|
+
body.inventory = connection.inventory;
|
|
610
|
+
await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
|
|
611
|
+
method: "POST",
|
|
612
|
+
body: JSON.stringify({ connection: body }),
|
|
613
|
+
});
|
|
614
|
+
// Deliberately no error handling beyond this: `request()` never throws, and
|
|
615
|
+
// this function does not even inspect the answer — a heartbeat is
|
|
616
|
+
// fire-and-forget by design (see the docblock above).
|
|
617
|
+
}
|
|
514
618
|
/**
|
|
515
619
|
* Mint, stream, re-mint — until a terminal outcome, which it writes as the one
|
|
516
620
|
* `stopped` record. Returns 0 when another listener took over, 1 otherwise.
|
|
517
621
|
*/
|
|
518
622
|
export async function runBridge(options, deps) {
|
|
519
623
|
const delivered = capResumeIds(options.resumeIds);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
624
|
+
// DX-3028 (AC1) — this process's own truthful state, reported every
|
|
625
|
+
// HEARTBEAT_INTERVAL_MS regardless of what the mint/stream loop is doing.
|
|
626
|
+
// `onState` (threaded into `runListener` below) is the ONLY thing that ever
|
|
627
|
+
// sets "streaming"; everything else (mint backoff, degraded idling) is
|
|
628
|
+
// "reconnecting" or "stopped". `heartbeatSuppressed` covers the two
|
|
629
|
+
// start-time-unauthenticatable degrade reasons (card AC2): the process has
|
|
630
|
+
// no credential it can report WITH, so it sends nothing at all rather than
|
|
631
|
+
// an unauthenticated POST that would just be refused.
|
|
632
|
+
let connectionState = "reconnecting";
|
|
633
|
+
let stoppedReason = null;
|
|
634
|
+
let heartbeatSuppressed = false;
|
|
635
|
+
let lastInventoryOk = true;
|
|
636
|
+
const heartbeatTick = () => {
|
|
637
|
+
if (heartbeatSuppressed)
|
|
638
|
+
return;
|
|
639
|
+
void sendHeartbeat(options, deps, connectionState === "streaming"
|
|
640
|
+
? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
|
|
641
|
+
: connectionState === "stopped"
|
|
642
|
+
? { state: "stopped", reason: stoppedReason ?? undefined }
|
|
643
|
+
: { state: "reconnecting" });
|
|
644
|
+
};
|
|
645
|
+
const heartbeat = deps.scheduleInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
|
|
646
|
+
async function settle(reason, detail, code, extra) {
|
|
647
|
+
// DX-2730 D4 — every settle goes through here, so this is the ONE place
|
|
648
|
+
// that cancels a pending digest timer. CANCELLED, never flushed: for
|
|
523
649
|
// `superseded`/`replaced` a newer process is already streaming this same
|
|
524
650
|
// session and will receive (and hold, and eventually flush) these same
|
|
525
651
|
// held events itself via the dashboard's replay — flushing here too would
|
|
526
|
-
// double-deliver the digest. For every other
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// otherwise also keep this process's
|
|
530
|
-
//
|
|
652
|
+
// double-deliver the digest. For every other reason, whatever is still
|
|
653
|
+
// held was never marked delivered (see `emitNow`), so a future process
|
|
654
|
+
// (or this same one, after a degrade retry) picks it up the same way — a
|
|
655
|
+
// live, un-fired `setTimeout` would otherwise also keep this process's
|
|
656
|
+
// event loop alive on top of the heartbeat interval, for no reason.
|
|
531
657
|
if (digestTimer !== null) {
|
|
532
658
|
clearTimeout(digestTimer);
|
|
533
659
|
digestTimer = null;
|
|
534
660
|
}
|
|
661
|
+
const policy = degradePolicyFor(reason);
|
|
662
|
+
const degraded = policy !== "exit";
|
|
663
|
+
const fix = reason === "scope_narrowed" ? scopeNarrowedFix(extra?.boards ?? []) : (extra?.fix ?? fixForStop(reason));
|
|
535
664
|
if (reason === "scope_narrowed") {
|
|
536
665
|
if (extra?.boards === undefined) {
|
|
537
|
-
throw new Error('
|
|
666
|
+
throw new Error('settle("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
|
|
538
667
|
}
|
|
539
|
-
deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix:
|
|
540
|
-
|
|
668
|
+
deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
|
|
541
672
|
}
|
|
542
|
-
|
|
543
|
-
|
|
673
|
+
if (policy === "exit") {
|
|
674
|
+
heartbeat.stop();
|
|
675
|
+
return { kind: "exit", code };
|
|
676
|
+
}
|
|
677
|
+
stoppedReason = reason;
|
|
678
|
+
connectionState = "stopped";
|
|
679
|
+
if (policy === "degrade-no-heartbeat") {
|
|
680
|
+
// DX-3028 (AC2) — "Three of these cannot report anything": this process
|
|
681
|
+
// has no credential it can authenticate a heartbeat WITH, so it sends
|
|
682
|
+
// none (`heartbeatSuppressed`) and has no basis to retry the mint
|
|
683
|
+
// itself either — it idles until the plugin, watching `watchedPaths`
|
|
684
|
+
// for a change, kills and restarts it fresh (a new process re-reads
|
|
685
|
+
// the credential from scratch). Never resolves on its own.
|
|
686
|
+
heartbeatSuppressed = true;
|
|
687
|
+
await new Promise(() => {
|
|
688
|
+
/* deliberately never settles — see the comment above */
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
// degrade-server-fixable: keep heartbeating `stopped`, and retry the
|
|
692
|
+
// mint/stream on our own after DEGRADED_RETRY_INTERVAL_MS, on top of the
|
|
693
|
+
// plugin's own watched-path restart (harmless overlap — whichever fires
|
|
694
|
+
// first wins; a restarted process just re-enters this same loop clean).
|
|
695
|
+
heartbeatSuppressed = false;
|
|
696
|
+
await deps.sleep(DEGRADED_RETRY_INTERVAL_MS);
|
|
697
|
+
return { kind: "retry" };
|
|
544
698
|
}
|
|
545
699
|
let backoff = MINT_INITIAL_BACKOFF_MS;
|
|
546
700
|
let refusedInRow = 0;
|
|
@@ -617,9 +771,15 @@ export async function runBridge(options, deps) {
|
|
|
617
771
|
emitNow(output);
|
|
618
772
|
}
|
|
619
773
|
for (;;) {
|
|
774
|
+
connectionState = "reconnecting";
|
|
620
775
|
const minted = await mintTicket(options, deps);
|
|
621
|
-
if (minted.kind === "terminal")
|
|
622
|
-
|
|
776
|
+
if (minted.kind === "terminal") {
|
|
777
|
+
const outcome = await settle(minted.reason, minted.detail, 1);
|
|
778
|
+
if (outcome.kind === "exit")
|
|
779
|
+
return outcome.code;
|
|
780
|
+
refusedInRow = 0;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
623
783
|
if (minted.kind === "transient") {
|
|
624
784
|
await deps.sleep(backoff);
|
|
625
785
|
backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
|
|
@@ -684,6 +844,13 @@ export async function runBridge(options, deps) {
|
|
|
684
844
|
run.emitted = true;
|
|
685
845
|
routeEvent(output);
|
|
686
846
|
},
|
|
847
|
+
// DX-3028 (AC1) — the only place `connectionState` ever becomes
|
|
848
|
+
// "streaming": `listen.ts`'s `connectOnce` calls this the moment the
|
|
849
|
+
// SSE connection is actually open, and the outer `for(;;)` above
|
|
850
|
+
// already resets it to "reconnecting" before every mint attempt.
|
|
851
|
+
onState: (state) => {
|
|
852
|
+
connectionState = state;
|
|
853
|
+
},
|
|
687
854
|
});
|
|
688
855
|
const stopped = run.stopped;
|
|
689
856
|
if (stopped === null)
|
|
@@ -692,10 +859,15 @@ export async function runBridge(options, deps) {
|
|
|
692
859
|
// `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
|
|
693
860
|
// for the compiler — leaving it a `Set` lookup here would keep every
|
|
694
861
|
// downstream branch's exhaustive narrowing (which is what makes the final
|
|
695
|
-
// fallthrough's `
|
|
862
|
+
// fallthrough's `settle(stopped.reason, ...)` call type-check against the
|
|
696
863
|
// closed `ListenStopReason` overload) from working.
|
|
697
|
-
if (stopped.reason === "superseded" || stopped.reason === "replaced")
|
|
698
|
-
|
|
864
|
+
if (stopped.reason === "superseded" || stopped.reason === "replaced") {
|
|
865
|
+
const outcome = await settle(stopped.reason, stopped.detail, 0);
|
|
866
|
+
if (outcome.kind === "exit")
|
|
867
|
+
return outcome.code;
|
|
868
|
+
refusedInRow = 0;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
699
871
|
if (stopped.reason === "lease_expired") {
|
|
700
872
|
refusedInRow = 0;
|
|
701
873
|
continue;
|
|
@@ -707,14 +879,21 @@ export async function runBridge(options, deps) {
|
|
|
707
879
|
// like its mint-time counterpart — never counted toward the generic
|
|
708
880
|
// refused-ticket retry budget below, which exists for an admission
|
|
709
881
|
// refusal with no more specific explanation than "not admitted".
|
|
710
|
-
|
|
882
|
+
const outcome = await settle(classified.reason, classified.detail, 1);
|
|
883
|
+
if (outcome.kind === "exit")
|
|
884
|
+
return outcome.code;
|
|
885
|
+
refusedInRow = 0;
|
|
886
|
+
continue;
|
|
711
887
|
}
|
|
712
888
|
}
|
|
713
889
|
if (stopped.reason === "refused") {
|
|
714
890
|
const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
|
|
715
891
|
refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
|
|
716
892
|
if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
|
|
717
|
-
|
|
893
|
+
const outcome = await settle("refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`, 1);
|
|
894
|
+
if (outcome.kind === "exit")
|
|
895
|
+
return outcome.code;
|
|
896
|
+
refusedInRow = 0;
|
|
718
897
|
}
|
|
719
898
|
continue;
|
|
720
899
|
}
|
|
@@ -725,9 +904,19 @@ export async function runBridge(options, deps) {
|
|
|
725
904
|
// `detail`) — no `?? []`, no optional check, `stopped.boards` IS
|
|
726
905
|
// `string[]` once narrowed to this branch.
|
|
727
906
|
const boards = stopped.boards;
|
|
728
|
-
|
|
907
|
+
const outcome = await settle("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
|
|
908
|
+
if (outcome.kind === "exit")
|
|
909
|
+
return outcome.code;
|
|
910
|
+
refusedInRow = 0;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
{
|
|
914
|
+
const outcome = await settle(stopped.reason, stopped.detail, 1);
|
|
915
|
+
if (outcome.kind === "exit")
|
|
916
|
+
return outcome.code;
|
|
917
|
+
refusedInRow = 0;
|
|
918
|
+
continue;
|
|
729
919
|
}
|
|
730
|
-
return stop(stopped.reason, stopped.detail, 1);
|
|
731
920
|
}
|
|
732
921
|
}
|
|
733
922
|
/** The bin's `bridge` subcommand, wired to the real process. */
|
|
@@ -751,13 +940,27 @@ resolveFrom = {}) {
|
|
|
751
940
|
}
|
|
752
941
|
catch (err) {
|
|
753
942
|
const start = err;
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// into (a hand run).
|
|
758
|
-
|
|
943
|
+
// DX-3028 (AC2) — a start-time failure still reports itself the way every
|
|
944
|
+
// other stop does — one stop record the plugin relays into the session
|
|
945
|
+
// (now carrying `paths`/`instanceId`/`degraded`) — AND on stderr, for the
|
|
946
|
+
// case where there is no session to relay into (a hand run).
|
|
947
|
+
const policy = degradePolicyFor(start.reason);
|
|
948
|
+
const degraded = policy !== "exit";
|
|
949
|
+
write({ type: "stopped", reason: start.reason, detail: start.message, fix: start.fix, instanceId: start.instanceId, paths: start.paths, degraded });
|
|
759
950
|
process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
|
|
760
|
-
|
|
951
|
+
if (!degraded)
|
|
952
|
+
return 1;
|
|
953
|
+
// DX-3028 (AC2) — `credential_unavailable` / `credential_mismatch`:
|
|
954
|
+
// this process has no credential it can authenticate a heartbeat with
|
|
955
|
+
// (see `settle`'s identical `degrade-no-heartbeat` branch), so it sends
|
|
956
|
+
// none and idles until the plugin kills and restarts it fresh on a
|
|
957
|
+
// watched-path change. `return`ed directly (never `await`ed then fallen
|
|
958
|
+
// through) so TypeScript's definite-assignment check for `options` below
|
|
959
|
+
// sees this branch as never reaching that line, which is also true at
|
|
960
|
+
// runtime — this promise never settles.
|
|
961
|
+
return new Promise(() => {
|
|
962
|
+
/* deliberately never settles */
|
|
963
|
+
});
|
|
761
964
|
}
|
|
762
965
|
return runBridge(options, {
|
|
763
966
|
fetch: (input, init) => fetch(input, init),
|
|
@@ -766,5 +969,9 @@ resolveFrom = {}) {
|
|
|
766
969
|
now: Date.now,
|
|
767
970
|
readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
|
|
768
971
|
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
|
972
|
+
scheduleInterval: (tick, ms) => {
|
|
973
|
+
const timer = setInterval(tick, ms);
|
|
974
|
+
return { stop: () => clearInterval(timer) };
|
|
975
|
+
},
|
|
769
976
|
});
|
|
770
977
|
}
|
package/dist/listen.js
CHANGED
|
@@ -541,6 +541,9 @@ export async function runListener(options, deps) {
|
|
|
541
541
|
await response.body?.cancel();
|
|
542
542
|
return { kind: "dropped", healthy: false, detail: `HTTP ${response.status} with no readable body` };
|
|
543
543
|
}
|
|
544
|
+
// DX-3028 — the connection is genuinely open and readable now; see
|
|
545
|
+
// `ListenDeps.onState`'s docblock for exactly what this reports.
|
|
546
|
+
deps.onState?.("streaming");
|
|
544
547
|
const parser = new SseParser();
|
|
545
548
|
const decoder = new TextDecoder();
|
|
546
549
|
for await (const chunk of response.body) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.95",
|
|
4
4
|
"description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|