@thehammer/danx-dashboard-mcp 0.1.86 → 0.1.87
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 +163 -7
- package/dist/listen.js +37 -11
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -89,6 +89,71 @@ import { oneLine } from "./one-line.js";
|
|
|
89
89
|
import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
|
|
90
90
|
import { readSessionConnection } from "./session-connection.js";
|
|
91
91
|
import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
|
|
92
|
+
/**
|
|
93
|
+
* DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
|
|
94
|
+
*
|
|
95
|
+
* Every `ListenEvent` carries the origin its writer set (`operator` / `agent` /
|
|
96
|
+
* `machine`, or `null` for the idle-nudge exemption and an event this listener
|
|
97
|
+
* could not parse — see `ListenEvent.origin`'s docblock in `listen.ts`). This
|
|
98
|
+
* module is the ONLY place that decides what happens next:
|
|
99
|
+
*
|
|
100
|
+
* - `operator` (a real human) and `null` (nudge / unreadable) emit AT ONCE,
|
|
101
|
+
* exactly as every event did before this card — a session must never wait
|
|
102
|
+
* to hear from the person it is working with, and a fault or a nudge must
|
|
103
|
+
* never be hidden behind a delay either.
|
|
104
|
+
* - `agent` and `machine` are HELD. Every dispatched agent's own writes and
|
|
105
|
+
* every server-side guard/reviewer/recovery write share this treatment:
|
|
106
|
+
* gate reviews, triage notes, auto-blocks. Held events are combined into
|
|
107
|
+
* ONE digest record, flushed the moment either `DIGEST_INTERVAL_MS`
|
|
108
|
+
* elapses with no operator event, or an operator event arrives (flushed
|
|
109
|
+
* just ahead of it, never after — a session must never read the digest
|
|
110
|
+
* as if it postdates the operator's own message).
|
|
111
|
+
*
|
|
112
|
+
* NOTHING HELD IS EVER DROPPED, AND NOTHING HELD IS EVER DOUBLE-DELIVERED
|
|
113
|
+
* EITHER — across a full process restart OR a same-process re-mint. An id is
|
|
114
|
+
* added to `delivered` (this run's own resume cursor, seeded from
|
|
115
|
+
* `options.resumeIds` and handed to the next mint/re-mint as `--resume-ids`)
|
|
116
|
+
* ONLY at the moment it is actually written to stdout — never at the moment it
|
|
117
|
+
* is merely held. So a process that dies mid-hold has, by construction, never
|
|
118
|
+
* marked those ids delivered: the next process starts from the SAME cursor,
|
|
119
|
+
* the dashboard's replay resends exactly the events this process never
|
|
120
|
+
* finished relaying, and they are held and flushed again there. This is "the
|
|
121
|
+
* existing resumeIds/delivered-id cursor" the card's AC names — no new
|
|
122
|
+
* persistence was needed, only NOT marking a held event delivered until it is
|
|
123
|
+
* actually flushed.
|
|
124
|
+
*
|
|
125
|
+
* THE SAME REPLAY CAN ALSO ARRIVE WITHIN ONE STILL-RUNNING PROCESS, on a
|
|
126
|
+
* same-process re-mint (a real `lease_expired`, not merely a dropped
|
|
127
|
+
* connection): a fresh `runListener()` call starts a FRESH internal duplicate
|
|
128
|
+
* guard, seeded only from the `resumeIds` this call hands it — and a held id
|
|
129
|
+
* was, by the paragraph above, never in `delivered`. Left unhandled this
|
|
130
|
+
* would let the SAME held event be counted into TWO digests once flushed
|
|
131
|
+
* twice. `heldIds()` closes this: every `runListener()` call is seeded with
|
|
132
|
+
* `[...delivered, ...heldIds()]`, so the fresh listener's own guard
|
|
133
|
+
* (`listen.ts`'s `delivered` Set) already knows about a held id and silently
|
|
134
|
+
* drops a replay of it before it ever reaches `routeEvent` again —
|
|
135
|
+
* `routeEvent`'s own id check on `held` is the second, cheaper layer behind
|
|
136
|
+
* that, for defense in depth.
|
|
137
|
+
*
|
|
138
|
+
* A digest record rides the exact same `{type:"event", id, text}` shape a
|
|
139
|
+
* single event does (`id` is the highest id it combines, or `null` if every
|
|
140
|
+
* combined event carried none) — the plugin that relays bridge output
|
|
141
|
+
* (`plan-event-bridge.mjs`, outside this repo) already forwards any such
|
|
142
|
+
* record generically, so a digest needs no change on that side.
|
|
143
|
+
*
|
|
144
|
+
* A REAL TIMER, NOT `deps.sleep`. Unlike every other timing decision in this
|
|
145
|
+
* module (mint backoff, reconnect backoff), the digest interval is not tied to
|
|
146
|
+
* stream activity — it must fire even while the stream sits idle between
|
|
147
|
+
* events — so it is a plain `setTimeout`, mockable with fake timers in tests,
|
|
148
|
+
* rather than routed through the deliberately stream-driven `deps.sleep`.
|
|
149
|
+
*/
|
|
150
|
+
export const DIGEST_INTERVAL_MS = 10 * 60_000;
|
|
151
|
+
const DIGEST_PREFIX = "[danx-dashboard bridge]";
|
|
152
|
+
/** ONE combined record for every event held since the last flush. */
|
|
153
|
+
function formatDigest(entries) {
|
|
154
|
+
const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
|
|
155
|
+
return [header, ...entries.map((e) => e.text)].join("\n");
|
|
156
|
+
}
|
|
92
157
|
export const BRIDGE_SUBCOMMAND = "bridge";
|
|
93
158
|
export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
|
|
94
159
|
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
@@ -394,6 +459,20 @@ function classifyAdmissionRefusal(refusal) {
|
|
|
394
459
|
export async function runBridge(options, deps) {
|
|
395
460
|
const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
|
|
396
461
|
function stop(reason, detail, code, extra) {
|
|
462
|
+
// DX-2730 D4 — every terminal return goes through here, so this is the ONE
|
|
463
|
+
// place that cancels a pending digest timer. CANCELLED, never flushed: for
|
|
464
|
+
// `superseded`/`replaced` a newer process is already streaming this same
|
|
465
|
+
// session and will receive (and hold, and eventually flush) these same
|
|
466
|
+
// held events itself via the dashboard's replay — flushing here too would
|
|
467
|
+
// double-deliver the digest. For every other terminal reason, whatever is
|
|
468
|
+
// still held was never marked delivered (see `emitNow`), so a future
|
|
469
|
+
// process picks it up the same way — a live, un-fired `setTimeout` would
|
|
470
|
+
// otherwise also keep this process's event loop alive for up to
|
|
471
|
+
// `DIGEST_INTERVAL_MS` after it should have exited.
|
|
472
|
+
if (digestTimer !== null) {
|
|
473
|
+
clearTimeout(digestTimer);
|
|
474
|
+
digestTimer = null;
|
|
475
|
+
}
|
|
397
476
|
if (reason === "scope_narrowed") {
|
|
398
477
|
if (extra?.boards === undefined) {
|
|
399
478
|
throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
|
|
@@ -407,6 +486,77 @@ export async function runBridge(options, deps) {
|
|
|
407
486
|
let backoff = MINT_INITIAL_BACKOFF_MS;
|
|
408
487
|
let refusedInRow = 0;
|
|
409
488
|
let readyEmitted = false;
|
|
489
|
+
// DX-2730 D4 — digest state, declared OUTSIDE the mint/reconnect loop below
|
|
490
|
+
// so a re-mint (a lapsed lease, still the SAME process) never loses a
|
|
491
|
+
// pending hold — see the module docblock for the restart case, which the
|
|
492
|
+
// resume cursor handles instead.
|
|
493
|
+
let held = [];
|
|
494
|
+
let digestTimer = null;
|
|
495
|
+
function markDelivered(id) {
|
|
496
|
+
if (id === null)
|
|
497
|
+
return;
|
|
498
|
+
delivered.push(id);
|
|
499
|
+
if (delivered.length > DELIVERED_ID_MEMORY)
|
|
500
|
+
delivered.shift();
|
|
501
|
+
}
|
|
502
|
+
/** Write an event downstream at once, and mark its id delivered — never before now. */
|
|
503
|
+
function emitNow(output) {
|
|
504
|
+
markDelivered(output.id);
|
|
505
|
+
deps.write(output);
|
|
506
|
+
}
|
|
507
|
+
/** Combine every held event into ONE record and emit it, if there is anything to combine. */
|
|
508
|
+
function flushDigest() {
|
|
509
|
+
if (digestTimer !== null) {
|
|
510
|
+
clearTimeout(digestTimer);
|
|
511
|
+
digestTimer = null;
|
|
512
|
+
}
|
|
513
|
+
if (held.length === 0)
|
|
514
|
+
return;
|
|
515
|
+
const entries = held;
|
|
516
|
+
held = [];
|
|
517
|
+
const ids = entries.map((e) => e.id).filter((id) => id !== null);
|
|
518
|
+
emitNow({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), text: formatDigest(entries), origin: null });
|
|
519
|
+
}
|
|
520
|
+
/** Every id currently held, unflushed — also fed back into the next `runListener` call's
|
|
521
|
+
* `resumeIds` below, so a re-mint's fresh listener knows these were already seen and its
|
|
522
|
+
* OWN duplicate guard (`listen.ts`'s `delivered` Set) silently drops a replay of one,
|
|
523
|
+
* rather than it reaching here a second time. */
|
|
524
|
+
function heldIds() {
|
|
525
|
+
return held.map((e) => e.id).filter((id) => id !== null);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* DX-2730 D4 — the ONE place that decides immediate vs. held-for-digest.
|
|
529
|
+
* `operator` and `null` (nudge / unreadable — see `ListenEvent.origin`'s
|
|
530
|
+
* docblock) flush any pending digest first (never let a digest arrive AFTER
|
|
531
|
+
* the operator event that should have superseded it) and then emit at once.
|
|
532
|
+
* `agent` / `machine` are appended to the hold, arming the interval timer
|
|
533
|
+
* only for the FIRST held event since the last flush — a later addition
|
|
534
|
+
* must never push the deadline out, or the "at most every 10 minutes"
|
|
535
|
+
* guarantee would erode into "10 minutes after the last event," which could
|
|
536
|
+
* never fire while events keep arriving.
|
|
537
|
+
*
|
|
538
|
+
* DEDUPED BY ID (defense in depth): a held event's id is not added to
|
|
539
|
+
* `delivered` until it is actually flushed (see `emitNow`), which is what
|
|
540
|
+
* lets a crashed-and-restarted PROCESS recover it via the dashboard's own
|
|
541
|
+
* replay. The same replay can also legitimately re-arrive WITHIN one still-
|
|
542
|
+
* running process — a re-mint after a real `lease_expired` starts a fresh
|
|
543
|
+
* `runListener` whose own duplicate guard is seeded from `resumeIds` alone,
|
|
544
|
+
* and a still-held id was never in `delivered` to seed it with. `resumeIds`
|
|
545
|
+
* below closes that for the ordinary case by handing the fresh listener
|
|
546
|
+
* every held id too; this check is the second, cheaper layer for anything
|
|
547
|
+
* that reaches here anyway — never trust a single guard to hold alone.
|
|
548
|
+
*/
|
|
549
|
+
function routeEvent(output) {
|
|
550
|
+
if (output.origin === "agent" || output.origin === "machine") {
|
|
551
|
+
if (output.id !== null && heldIds().includes(output.id))
|
|
552
|
+
return;
|
|
553
|
+
held.push(output);
|
|
554
|
+
digestTimer ??= setTimeout(flushDigest, DIGEST_INTERVAL_MS);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
flushDigest();
|
|
558
|
+
emitNow(output);
|
|
559
|
+
}
|
|
410
560
|
for (;;) {
|
|
411
561
|
const minted = await mintTicket(options, deps);
|
|
412
562
|
if (minted.kind === "terminal")
|
|
@@ -427,7 +577,18 @@ export async function runBridge(options, deps) {
|
|
|
427
577
|
backoff = MINT_INITIAL_BACKOFF_MS;
|
|
428
578
|
const run = { stopped: null, emitted: false };
|
|
429
579
|
const startedAt = deps.now();
|
|
430
|
-
await runListener(
|
|
580
|
+
await runListener(
|
|
581
|
+
// DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
|
|
582
|
+
// not-yet-flushed event was never added to `delivered` (see `emitNow`),
|
|
583
|
+
// so without this a fresh listener after a re-mint would not recognize
|
|
584
|
+
// a replay of it as already-seen and would hold (and eventually
|
|
585
|
+
// digest) it a second time. See `routeEvent`'s docblock. (If this
|
|
586
|
+
// combined list ever exceeded `DELIVERED_ID_MEMORY`, `runListener`'s own
|
|
587
|
+
// `slice(-DELIVERED_ID_MEMORY)` would evict the OLDEST entries first —
|
|
588
|
+
// i.e. `delivered`'s ids before `heldIds()`'s, which is the right order:
|
|
589
|
+
// a held id is always the more recent of the two and the one a near-
|
|
590
|
+
// term replay is actually likely to touch.)
|
|
591
|
+
{ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered, ...heldIds()] }, {
|
|
431
592
|
...deps,
|
|
432
593
|
write: (output) => {
|
|
433
594
|
if (output.type === "stopped") {
|
|
@@ -435,12 +596,7 @@ export async function runBridge(options, deps) {
|
|
|
435
596
|
return;
|
|
436
597
|
}
|
|
437
598
|
run.emitted = true;
|
|
438
|
-
|
|
439
|
-
delivered.push(output.id);
|
|
440
|
-
if (delivered.length > DELIVERED_ID_MEMORY)
|
|
441
|
-
delivered.shift();
|
|
442
|
-
}
|
|
443
|
-
deps.write(output);
|
|
599
|
+
routeEvent(output);
|
|
444
600
|
},
|
|
445
601
|
});
|
|
446
602
|
const stopped = run.stopped;
|
package/dist/listen.js
CHANGED
|
@@ -64,6 +64,7 @@ export const HEALTHY_CONNECTION_MS = 20_000;
|
|
|
64
64
|
/** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
|
|
65
65
|
export const DELIVERED_ID_MEMORY = 1_000;
|
|
66
66
|
const LINE_PREFIX = "[danx-dashboard listen]";
|
|
67
|
+
const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
|
|
67
68
|
function quoted(value) {
|
|
68
69
|
return `"${value.text}${value.truncated ? "…" : ""}"`;
|
|
69
70
|
}
|
|
@@ -91,6 +92,11 @@ export function invalidEventReason(value) {
|
|
|
91
92
|
if (typeof value[field] !== "string")
|
|
92
93
|
return `${field} is not a string`;
|
|
93
94
|
}
|
|
95
|
+
// DX-2730 D4 — checked with the rest of the envelope, before any kind-specific
|
|
96
|
+
// detail check, exactly like every other envelope field above.
|
|
97
|
+
if (typeof value.origin !== "string" || !ACTIVITY_ORIGINS.has(value.origin)) {
|
|
98
|
+
return "origin is not one of operator, agent, machine";
|
|
99
|
+
}
|
|
94
100
|
const d = value.detail;
|
|
95
101
|
if (!isRecord(d))
|
|
96
102
|
return "detail is not an object";
|
|
@@ -157,9 +163,19 @@ export function invalidEventReason(value) {
|
|
|
157
163
|
return null;
|
|
158
164
|
}
|
|
159
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* DX-2730 D4 — `actor` alone cannot distinguish an `agent` event from a
|
|
168
|
+
* `machine` one (both stamp `actor: "danxbot"` today), so every line names
|
|
169
|
+
* the origin next to the actor rather than leaving it to a separate field a
|
|
170
|
+
* reader would have to cross-reference.
|
|
171
|
+
*/
|
|
172
|
+
function actorLabel(event) {
|
|
173
|
+
return `${event.actor} (${event.origin})`;
|
|
174
|
+
}
|
|
160
175
|
/** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
|
|
161
176
|
function describe(event) {
|
|
162
177
|
const d = event.detail;
|
|
178
|
+
const who = actorLabel(event);
|
|
163
179
|
switch (event.kind) {
|
|
164
180
|
case "comment_added": {
|
|
165
181
|
// DX-2906 — a comment naming a problem (a follow-up question, without
|
|
@@ -168,15 +184,15 @@ function describe(event) {
|
|
|
168
184
|
// got a reply.
|
|
169
185
|
const problem = d.problem;
|
|
170
186
|
if (problem === undefined)
|
|
171
|
-
return `${
|
|
172
|
-
return `${
|
|
187
|
+
return `${who} commented: ${quoted(d.excerpt)}`;
|
|
188
|
+
return `${who} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
|
|
173
189
|
}
|
|
174
190
|
case "solution_answered": {
|
|
175
191
|
// DX-2735: every answer answers ONE problem on a card that may carry several,
|
|
176
192
|
// so the line names the problem — otherwise the agent cannot tell which of its
|
|
177
193
|
// questions just closed.
|
|
178
194
|
const problem = d.problem;
|
|
179
|
-
const answered = `${
|
|
195
|
+
const answered = `${who} answered ${quoted(problem.statement)}:`;
|
|
180
196
|
const solution = d.solution;
|
|
181
197
|
if (solution === null)
|
|
182
198
|
return `${answered} ${quoted(d.freeform)}`;
|
|
@@ -187,14 +203,14 @@ function describe(event) {
|
|
|
187
203
|
// DX-2830 — this IS the "needs a human" signal now: a card needs one
|
|
188
204
|
// exactly when it has an open problem, and this is one being opened.
|
|
189
205
|
const problem = d.problem;
|
|
190
|
-
return `${
|
|
206
|
+
return `${who} opened a problem: ${quoted(problem.statement)}`;
|
|
191
207
|
}
|
|
192
208
|
case "problem_unanswered": {
|
|
193
209
|
// DX-2935 — MUST read as "a previous answer was withdrawn", never as a
|
|
194
210
|
// brand-new problem: a connected session that already acted on the
|
|
195
211
|
// retracted answer has to stop, not treat this as fresh work to start.
|
|
196
212
|
const problem = d.problem;
|
|
197
|
-
return `${
|
|
213
|
+
return `${who} retracted the answer to ${quoted(problem.statement)}`;
|
|
198
214
|
}
|
|
199
215
|
case "problem_answer_changed": {
|
|
200
216
|
// DX-2935 — ONE line for the whole retract-and-reanswer (never two
|
|
@@ -203,16 +219,16 @@ function describe(event) {
|
|
|
203
219
|
const problem = d.problem;
|
|
204
220
|
const solution = d.solution;
|
|
205
221
|
const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
|
|
206
|
-
return `${
|
|
222
|
+
return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
|
|
207
223
|
}
|
|
208
224
|
case "blocked":
|
|
209
|
-
return `${
|
|
225
|
+
return `${who} blocked the card: ${quoted(d.reason)}`;
|
|
210
226
|
case "unblocked":
|
|
211
|
-
return `${
|
|
227
|
+
return `${who} unblocked the card`;
|
|
212
228
|
default:
|
|
213
229
|
// A kind newer than this listener still produces a line: an event the
|
|
214
230
|
// agent is never told about is exactly the failure this reader prevents.
|
|
215
|
-
return `${
|
|
231
|
+
return `${who}: ${event.kind}`;
|
|
216
232
|
}
|
|
217
233
|
}
|
|
218
234
|
/** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
|
|
@@ -417,7 +433,11 @@ export async function runListener(options, deps) {
|
|
|
417
433
|
const text = invalid === null
|
|
418
434
|
? formatNudgeLine(parsed)
|
|
419
435
|
: `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
|
|
420
|
-
|
|
436
|
+
// DX-2730 D4 — `origin: null` here, not the parsed event's own origin
|
|
437
|
+
// (a nudge has none): this is the exemption the module doc above
|
|
438
|
+
// describes, letting `bridge.ts` treat it exactly like an operator
|
|
439
|
+
// event (immediate) without needing a fourth vocabulary member.
|
|
440
|
+
deps.write({ type: "event", id: null, text, origin: null });
|
|
421
441
|
return null;
|
|
422
442
|
}
|
|
423
443
|
if (message.event !== "activity")
|
|
@@ -447,7 +467,13 @@ export async function runListener(options, deps) {
|
|
|
447
467
|
// DX-2735: oneLine caps by code point, so a bad event carrying an emoji
|
|
448
468
|
// at the cut can never leave a lone surrogate in the line.
|
|
449
469
|
oneLine(message.data, 300);
|
|
450
|
-
|
|
470
|
+
// DX-2730 D4 — an event this listener could not parse carries `origin: null`
|
|
471
|
+
// (emit immediately) rather than the unreadable payload's own claimed
|
|
472
|
+
// origin: a fault worth surfacing at all must never be hidden behind a
|
|
473
|
+
// 10-minute digest wait just because it happened to come from an agent or
|
|
474
|
+
// a machine writer.
|
|
475
|
+
const origin = invalid === null ? parsed.origin : null;
|
|
476
|
+
deps.write({ type: "event", id: knownId ? id : null, text, origin });
|
|
451
477
|
if (knownId)
|
|
452
478
|
remember(id);
|
|
453
479
|
return null;
|
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.87",
|
|
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",
|