@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 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({ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered] }, {
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
- if (output.id !== null) {
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 `${event.actor} commented: ${quoted(d.excerpt)}`;
172
- return `${event.actor} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
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 = `${event.actor} answered ${quoted(problem.statement)}:`;
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 `${event.actor} opened a problem: ${quoted(problem.statement)}`;
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 `${event.actor} retracted the answer to ${quoted(problem.statement)}`;
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 `${event.actor} changed the answer to ${quoted(problem.statement)}: now ${now}`;
222
+ return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
207
223
  }
208
224
  case "blocked":
209
- return `${event.actor} blocked the card: ${quoted(d.reason)}`;
225
+ return `${who} blocked the card: ${quoted(d.reason)}`;
210
226
  case "unblocked":
211
- return `${event.actor} unblocked the card`;
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 `${event.actor}: ${event.kind}`;
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
- deps.write({ type: "event", id: null, text });
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
- deps.write({ type: "event", id: knownId ? id : null, text });
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.86",
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",