@thehammer/danx-dashboard-mcp 0.1.88 → 0.1.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/bridge.js +365 -154
- package/dist/handlers.js +94 -2
- package/dist/index.js +66 -16
- package/dist/json-body.js +23 -0
- package/dist/listen.js +324 -27
- package/dist/one-line.js +3 -2
- package/dist/plan-state.js +194 -0
- package/package.json +1 -1
package/dist/listen.js
CHANGED
|
@@ -11,10 +11,31 @@
|
|
|
11
11
|
* - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
|
|
12
12
|
* `text` is what the session reads; an event it cannot read still produces one
|
|
13
13
|
* (`could not read event …`), never silence. `id` is `null` only for a frame
|
|
14
|
-
* that carried no usable id
|
|
15
|
-
*
|
|
16
|
-
* own
|
|
17
|
-
*
|
|
14
|
+
* that carried no usable id — which includes DX-2885's `nudge` frame
|
|
15
|
+
* (`formatNudgeLine`): it is advisory, re-derived fresh every tick by the
|
|
16
|
+
* dashboard's own keep-alive tick, and never replayed, so it rides this
|
|
17
|
+
* same shape with `id: null` rather than growing a THIRD output variant.
|
|
18
|
+
* - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
|
|
19
|
+
* is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
|
|
20
|
+
* `scope_narrowed`, `not_connected`), `refused` (the ticket was not
|
|
21
|
+
* admitted), `bad_end_payload` (a `scope_narrowed` end whose `boards` array
|
|
22
|
+
* was missing/invalid — a protocol error, never silently downgraded), or
|
|
23
|
+
* `lease_expired` (no healthy connection for the lease). DX-2920 —
|
|
24
|
+
* `refusal` is populated ONLY for `reason === "refused"`: `{status,
|
|
25
|
+
* errorCode, body}`, the admission response parsed ONCE here, so a
|
|
26
|
+
* consumer can recognize a SPECIFIC refusal (the dashboard's own
|
|
27
|
+
* `409 session_not_connected`, the same shape the mint route uses) by
|
|
28
|
+
* status + error code rather than re-deriving it from the flattened
|
|
29
|
+
* `detail` string. `boards` is REQUIRED (round 4 — a discriminated union,
|
|
30
|
+
* not an optional field: see `ListenStopped` / `ListenStopReason`) for
|
|
31
|
+
* `reason === "scope_narrowed"` and absent for every other reason, parsed
|
|
32
|
+
* from the dashboard's `end` payload the SAME way `reason` itself is
|
|
33
|
+
* parsed — never re-derived from `detail`. A wire `reason` value this
|
|
34
|
+
* build does not recognize (a newer dashboard's reason an older bridge
|
|
35
|
+
* hasn't shipped yet) normalizes to `"unknown"` rather than passing the
|
|
36
|
+
* raw string through, so the closed set stays closed — `fixForStop`'s
|
|
37
|
+
* generic `DEFAULT_STOP_FIX` handles it exactly as it already handled a
|
|
38
|
+
* verbatim unrecognized string.
|
|
18
39
|
* - nothing for keep-alives, the connect marker, or a successful reconnect.
|
|
19
40
|
*
|
|
20
41
|
* RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
|
|
@@ -32,6 +53,7 @@
|
|
|
32
53
|
* (`src/issues/db/issue-activity.ts`); this published package cannot import
|
|
33
54
|
* danxbot source.
|
|
34
55
|
*/
|
|
56
|
+
import { errorCodeOf } from "./json-body.js";
|
|
35
57
|
import { oneLine } from "./one-line.js";
|
|
36
58
|
export const INITIAL_BACKOFF_MS = 1_000;
|
|
37
59
|
export const MAX_BACKOFF_MS = 30_000;
|
|
@@ -41,7 +63,18 @@ export const READ_IDLE_TIMEOUT_MS = 45_000;
|
|
|
41
63
|
export const HEALTHY_CONNECTION_MS = 20_000;
|
|
42
64
|
/** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
|
|
43
65
|
export const DELIVERED_ID_MEMORY = 1_000;
|
|
44
|
-
|
|
66
|
+
/**
|
|
67
|
+
* DX-2829 — the ONE place that caps a resume-id list to the last
|
|
68
|
+
* `DELIVERED_ID_MEMORY` entries. Both `runListener` (seeding its own
|
|
69
|
+
* duplicate-detection `Set` below) and `bridge.ts`'s `runBridge` (seeding its
|
|
70
|
+
* own delivered-id array) used to each carry their own `slice(-DELIVERED_ID_MEMORY)`
|
|
71
|
+
* — the same cap, on the same kind of input, computed twice.
|
|
72
|
+
*/
|
|
73
|
+
export function capResumeIds(ids) {
|
|
74
|
+
return ids.slice(-DELIVERED_ID_MEMORY);
|
|
75
|
+
}
|
|
76
|
+
const LINE_PREFIX = "[danx-dashboard stream]";
|
|
77
|
+
const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
|
|
45
78
|
function quoted(value) {
|
|
46
79
|
return `"${value.text}${value.truncated ? "…" : ""}"`;
|
|
47
80
|
}
|
|
@@ -69,12 +102,27 @@ export function invalidEventReason(value) {
|
|
|
69
102
|
if (typeof value[field] !== "string")
|
|
70
103
|
return `${field} is not a string`;
|
|
71
104
|
}
|
|
105
|
+
// DX-2730 D4 — checked with the rest of the envelope, before any kind-specific
|
|
106
|
+
// detail check, exactly like every other envelope field above.
|
|
107
|
+
if (typeof value.origin !== "string" || !ACTIVITY_ORIGINS.has(value.origin)) {
|
|
108
|
+
return "origin is not one of operator, agent, machine";
|
|
109
|
+
}
|
|
72
110
|
const d = value.detail;
|
|
73
111
|
if (!isRecord(d))
|
|
74
112
|
return "detail is not an object";
|
|
75
113
|
switch (value.kind) {
|
|
76
|
-
case "comment_added":
|
|
77
|
-
|
|
114
|
+
case "comment_added": {
|
|
115
|
+
if (!isCappedText(d.excerpt))
|
|
116
|
+
return "detail.excerpt is not capped text";
|
|
117
|
+
// DX-2906 — `problem` is OPTIONAL (only present when the comment named
|
|
118
|
+
// one); absent is valid, but a present-and-malformed one is not.
|
|
119
|
+
if (d.problem === undefined)
|
|
120
|
+
return null;
|
|
121
|
+
const problem = d.problem;
|
|
122
|
+
return isRecord(problem) && typeof problem.id === "number" && isCappedText(problem.statement)
|
|
123
|
+
? null
|
|
124
|
+
: "detail.problem is not {id, statement}";
|
|
125
|
+
}
|
|
78
126
|
case "solution_answered": {
|
|
79
127
|
const problem = d.problem;
|
|
80
128
|
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
@@ -93,24 +141,68 @@ export function invalidEventReason(value) {
|
|
|
93
141
|
}
|
|
94
142
|
return null;
|
|
95
143
|
}
|
|
144
|
+
// DX-2935 — the operator retracted a problem's answer with no new one yet.
|
|
145
|
+
case "problem_unanswered": {
|
|
146
|
+
const problem = d.problem;
|
|
147
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
148
|
+
return "detail.problem is not {id, statement}";
|
|
149
|
+
}
|
|
150
|
+
return typeof d.decision_id === "number" ? null : "detail.decision_id is not a number";
|
|
151
|
+
}
|
|
152
|
+
// DX-2935 — the operator retracted a problem's answer AND recorded a new
|
|
153
|
+
// one, in one transaction — same either/or solution/freeform shape
|
|
154
|
+
// `solution_answered` carries, plus which decision was retracted.
|
|
155
|
+
case "problem_answer_changed": {
|
|
156
|
+
const problem = d.problem;
|
|
157
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
158
|
+
return "detail.problem is not {id, statement}";
|
|
159
|
+
}
|
|
160
|
+
if (typeof d.retracted_decision_id !== "number")
|
|
161
|
+
return "detail.retracted_decision_id is not a number";
|
|
162
|
+
if (typeof d.decision_id !== "number")
|
|
163
|
+
return "detail.decision_id is not a number";
|
|
164
|
+
if (d.solution === null)
|
|
165
|
+
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
166
|
+
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
167
|
+
return "detail.solution is not {title, note}";
|
|
168
|
+
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
169
|
+
}
|
|
96
170
|
case "blocked":
|
|
97
171
|
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
98
172
|
default:
|
|
99
173
|
return null;
|
|
100
174
|
}
|
|
101
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* DX-2730 D4 — `actor` alone cannot distinguish an `agent` event from a
|
|
178
|
+
* `machine` one (both stamp `actor: "danxbot"` today), so every line names
|
|
179
|
+
* the origin next to the actor rather than leaving it to a separate field a
|
|
180
|
+
* reader would have to cross-reference.
|
|
181
|
+
*/
|
|
182
|
+
function actorLabel(event) {
|
|
183
|
+
return `${event.actor} (${event.origin})`;
|
|
184
|
+
}
|
|
102
185
|
/** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
|
|
103
186
|
function describe(event) {
|
|
104
187
|
const d = event.detail;
|
|
188
|
+
const who = actorLabel(event);
|
|
105
189
|
switch (event.kind) {
|
|
106
|
-
case "comment_added":
|
|
107
|
-
|
|
190
|
+
case "comment_added": {
|
|
191
|
+
// DX-2906 — a comment naming a problem (a follow-up question, without
|
|
192
|
+
// answering it) relays distinctly from a plain comment, so an agent
|
|
193
|
+
// listening to the stream can tell which of its open problems just
|
|
194
|
+
// got a reply.
|
|
195
|
+
const problem = d.problem;
|
|
196
|
+
if (problem === undefined)
|
|
197
|
+
return `${who} commented: ${quoted(d.excerpt)}`;
|
|
198
|
+
return `${who} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
|
|
199
|
+
}
|
|
108
200
|
case "solution_answered": {
|
|
109
201
|
// DX-2735: every answer answers ONE problem on a card that may carry several,
|
|
110
202
|
// so the line names the problem — otherwise the agent cannot tell which of its
|
|
111
203
|
// questions just closed.
|
|
112
204
|
const problem = d.problem;
|
|
113
|
-
const answered = `${
|
|
205
|
+
const answered = `${who} answered ${quoted(problem.statement)}:`;
|
|
114
206
|
const solution = d.solution;
|
|
115
207
|
if (solution === null)
|
|
116
208
|
return `${answered} ${quoted(d.freeform)}`;
|
|
@@ -121,16 +213,32 @@ function describe(event) {
|
|
|
121
213
|
// DX-2830 — this IS the "needs a human" signal now: a card needs one
|
|
122
214
|
// exactly when it has an open problem, and this is one being opened.
|
|
123
215
|
const problem = d.problem;
|
|
124
|
-
return `${
|
|
216
|
+
return `${who} opened a problem: ${quoted(problem.statement)}`;
|
|
217
|
+
}
|
|
218
|
+
case "problem_unanswered": {
|
|
219
|
+
// DX-2935 — MUST read as "a previous answer was withdrawn", never as a
|
|
220
|
+
// brand-new problem: a connected session that already acted on the
|
|
221
|
+
// retracted answer has to stop, not treat this as fresh work to start.
|
|
222
|
+
const problem = d.problem;
|
|
223
|
+
return `${who} retracted the answer to ${quoted(problem.statement)}`;
|
|
224
|
+
}
|
|
225
|
+
case "problem_answer_changed": {
|
|
226
|
+
// DX-2935 — ONE line for the whole retract-and-reanswer (never two
|
|
227
|
+
// separate events), naming the statement so a listener with several
|
|
228
|
+
// open questions knows which one just moved.
|
|
229
|
+
const problem = d.problem;
|
|
230
|
+
const solution = d.solution;
|
|
231
|
+
const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
|
|
232
|
+
return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
|
|
125
233
|
}
|
|
126
234
|
case "blocked":
|
|
127
|
-
return `${
|
|
235
|
+
return `${who} blocked the card: ${quoted(d.reason)}`;
|
|
128
236
|
case "unblocked":
|
|
129
|
-
return `${
|
|
237
|
+
return `${who} unblocked the card`;
|
|
130
238
|
default:
|
|
131
239
|
// A kind newer than this listener still produces a line: an event the
|
|
132
240
|
// agent is never told about is exactly the failure this reader prevents.
|
|
133
|
-
return `${
|
|
241
|
+
return `${who}: ${event.kind}`;
|
|
134
242
|
}
|
|
135
243
|
}
|
|
136
244
|
/** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
|
|
@@ -140,6 +248,46 @@ export function formatActivityLine(event) {
|
|
|
140
248
|
throw new Error(`malformed activity event: ${invalid}`);
|
|
141
249
|
return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
|
|
142
250
|
}
|
|
251
|
+
function isNudgeCardRef(value) {
|
|
252
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
|
|
253
|
+
}
|
|
254
|
+
function isNudgeCardRefArray(value) {
|
|
255
|
+
return Array.isArray(value) && value.every(isNudgeCardRef);
|
|
256
|
+
}
|
|
257
|
+
/** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
|
|
258
|
+
export function invalidNudgeReason(value) {
|
|
259
|
+
if (!isRecord(value))
|
|
260
|
+
return "the nudge is not an object";
|
|
261
|
+
if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
|
|
262
|
+
return "idleMinutes is not a number";
|
|
263
|
+
}
|
|
264
|
+
if (!isNudgeCardRefArray(value.startable))
|
|
265
|
+
return "startable is not a {id,title}[] array";
|
|
266
|
+
if (!isNudgeCardRefArray(value.held))
|
|
267
|
+
return "held is not a {id,title}[] array";
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function cardList(cards) {
|
|
271
|
+
return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The one readable line for a nudge (DX-2885). Always names the actual
|
|
275
|
+
* waiting cards — never a generic "keep going" — because that is the whole
|
|
276
|
+
* point of the feature: a session that only sees "you're idle" learns
|
|
277
|
+
* nothing a bare timer couldn't have told it. Always a single line; throws,
|
|
278
|
+
* naming the fault, on a malformed nudge.
|
|
279
|
+
*/
|
|
280
|
+
export function formatNudgeLine(event) {
|
|
281
|
+
const invalid = invalidNudgeReason(event);
|
|
282
|
+
if (invalid !== null)
|
|
283
|
+
throw new Error(`malformed nudge event: ${invalid}`);
|
|
284
|
+
const parts = [];
|
|
285
|
+
if (event.startable.length > 0)
|
|
286
|
+
parts.push(`startable: ${cardList(event.startable)}`);
|
|
287
|
+
if (event.held.length > 0)
|
|
288
|
+
parts.push(`held: ${cardList(event.held)}`);
|
|
289
|
+
return oneLine(`${LINE_PREFIX} this session has been idle ${event.idleMinutes}m with work waiting — ${parts.join("; ")}`);
|
|
290
|
+
}
|
|
143
291
|
/**
|
|
144
292
|
* Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
|
|
145
293
|
* field-less blocks produce no message, which is what keeps them out of the output.
|
|
@@ -181,6 +329,27 @@ function parseBlock(block) {
|
|
|
181
329
|
}
|
|
182
330
|
return sawField ? { id, event, data: data.join("\n") } : null;
|
|
183
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
|
|
334
|
+
* client recognizes on the wire, EXCLUDING `scope_narrowed` (parsed by its own
|
|
335
|
+
* dedicated branch in `handle()` below, since it alone carries `boards`).
|
|
336
|
+
* Hand-copied from `StreamEndReason` in `src/issues/plan-session-listeners.ts`
|
|
337
|
+
* — this published package cannot import danxbot source. A wire `reason` this
|
|
338
|
+
* set does not contain (a newer dashboard's reason this build predates)
|
|
339
|
+
* normalizes to `"unknown"` rather than passing the raw string through, which
|
|
340
|
+
* is what lets `Outcome`'s `reason` field below be a closed union instead of
|
|
341
|
+
* `string` — see `ListenStopReason`'s docblock for why that closure is what
|
|
342
|
+
* makes the `scope_narrowed`-requires-`boards` invariant an actual compile
|
|
343
|
+
* error rather than a comment's promise.
|
|
344
|
+
*/
|
|
345
|
+
const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
|
|
346
|
+
function isKnownWireEndReason(reason) {
|
|
347
|
+
return KNOWN_WIRE_END_REASONS.has(reason);
|
|
348
|
+
}
|
|
349
|
+
/** A thrown value's message, for the one drop path that carries a real JS error — never re-derived elsewhere. */
|
|
350
|
+
function describeDropError(err) {
|
|
351
|
+
return err instanceof Error ? err.message : String(err);
|
|
352
|
+
}
|
|
184
353
|
/** Statuses that describe the moment, not the ticket — retry them. */
|
|
185
354
|
const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
|
|
186
355
|
/**
|
|
@@ -193,22 +362,102 @@ export async function runListener(options, deps) {
|
|
|
193
362
|
let lastEventId = null;
|
|
194
363
|
let unhealthySince = null;
|
|
195
364
|
let attempt = 0;
|
|
365
|
+
// DX-2829 — the most recent `dropped` outcome's cause, carried across
|
|
366
|
+
// reconnect attempts so the eventual `lease_expired` stop (the only outcome
|
|
367
|
+
// reached exclusively via repeated drops) can say what actually failed
|
|
368
|
+
// instead of only "no healthy connection ... past the ticket's lease".
|
|
369
|
+
let lastDropDetail = null;
|
|
196
370
|
const remember = (id) => {
|
|
197
371
|
delivered.add(id);
|
|
198
372
|
if (delivered.size > DELIVERED_ID_MEMORY)
|
|
199
373
|
delivered.delete(delivered.values().next().value);
|
|
200
374
|
lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
|
|
201
375
|
};
|
|
202
|
-
for (const id of options.resumeIds
|
|
376
|
+
for (const id of capResumeIds(options.resumeIds))
|
|
203
377
|
remember(id);
|
|
204
|
-
|
|
205
|
-
|
|
378
|
+
function stop(reason, detail, code, extra) {
|
|
379
|
+
if (reason === "scope_narrowed") {
|
|
380
|
+
if (extra?.boards === undefined) {
|
|
381
|
+
throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
|
|
382
|
+
}
|
|
383
|
+
deps.write({ type: "stopped", reason, detail, boards: extra.boards });
|
|
384
|
+
return code;
|
|
385
|
+
}
|
|
386
|
+
deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
|
|
206
387
|
return code;
|
|
207
|
-
}
|
|
388
|
+
}
|
|
208
389
|
const handle = (message) => {
|
|
209
390
|
if (message.event === "end") {
|
|
210
|
-
|
|
211
|
-
|
|
391
|
+
// DX-2920 (round 3) — a JSON.parse failure here falls through to the
|
|
392
|
+
// pre-existing "unknown" reason below via `isRecord(null)`, unchanged
|
|
393
|
+
// from before this round. Only a `scope_narrowed` reason additionally
|
|
394
|
+
// REQUIRES a valid `boards` string array (matching the server's `end`
|
|
395
|
+
// overload, which cannot emit `scope_narrowed` without one) — a
|
|
396
|
+
// `scope_narrowed` frame that fails that check is a PROTOCOL error
|
|
397
|
+
// (a server too old to have sent boards, or a corrupted frame), never
|
|
398
|
+
// silently downgraded to the generic "the dashboard ended this
|
|
399
|
+
// listener" message the caller cannot act on.
|
|
400
|
+
let parsed;
|
|
401
|
+
try {
|
|
402
|
+
parsed = JSON.parse(message.data);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
parsed = null;
|
|
406
|
+
}
|
|
407
|
+
const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
|
|
408
|
+
const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
|
|
409
|
+
if (reason === "scope_narrowed") {
|
|
410
|
+
const boardsRaw = isRecord(parsed) ? parsed.boards : undefined;
|
|
411
|
+
const boards = Array.isArray(boardsRaw) && boardsRaw.length > 0 && boardsRaw.every((b) => typeof b === "string")
|
|
412
|
+
? boardsRaw
|
|
413
|
+
: null;
|
|
414
|
+
if (boards === null) {
|
|
415
|
+
return { kind: "ended", reason: "bad_end_payload" };
|
|
416
|
+
}
|
|
417
|
+
return { kind: "ended", reason, boards };
|
|
418
|
+
}
|
|
419
|
+
// DX-2920 (round 4) — a wire reason this build does not recognize (a
|
|
420
|
+
// newer dashboard's reason an older bridge hasn't shipped yet)
|
|
421
|
+
// normalizes to "unknown" rather than passing the raw string through:
|
|
422
|
+
// `Outcome`'s "ended" reason is now a closed set (`EndedReason`), and an
|
|
423
|
+
// open passthrough here would be exactly the hole that lets a
|
|
424
|
+
// missing-`boards` "scope_narrowed" construction slip past the compiler
|
|
425
|
+
// elsewhere — see `ListenStopReason`'s docblock.
|
|
426
|
+
return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
|
|
427
|
+
}
|
|
428
|
+
if (message.event === "nudge") {
|
|
429
|
+
// DX-2885 — a nudge carries no usable id (it is advisory, re-derived
|
|
430
|
+
// fresh every tick, never replayed — see `plan-session-stream.ts`'s
|
|
431
|
+
// module doc), so it rides the SAME `{type:"event", id:null, text}`
|
|
432
|
+
// shape an ordinary activity line uses (`id` is `null` only for a frame
|
|
433
|
+
// that carried no usable id — a nudge is exactly that case). That is
|
|
434
|
+
// what lets `bridge.ts` and the plugin's relay
|
|
435
|
+
// (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
|
|
436
|
+
// it with NO changes at all: both already forward any `{type:"event",
|
|
437
|
+
// id, text}` line generically, keyed only on `type`.
|
|
438
|
+
//
|
|
439
|
+
// DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
|
|
440
|
+
// batching when that lands (see DX-2730 AC 29939, and the matching note
|
|
441
|
+
// at the dashboard's own emit site): it must keep delivering
|
|
442
|
+
// immediately, never held for a later digest flush.
|
|
443
|
+
let parsed;
|
|
444
|
+
let invalid;
|
|
445
|
+
try {
|
|
446
|
+
parsed = JSON.parse(message.data);
|
|
447
|
+
invalid = invalidNudgeReason(parsed);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
invalid = `not JSON: ${err.message}`;
|
|
451
|
+
}
|
|
452
|
+
const text = invalid === null
|
|
453
|
+
? formatNudgeLine(parsed)
|
|
454
|
+
: `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
|
|
455
|
+
// DX-2730 D4 — `origin: null` here, not the parsed event's own origin
|
|
456
|
+
// (a nudge has none): this is the exemption the module doc above
|
|
457
|
+
// describes, letting `bridge.ts` treat it exactly like an operator
|
|
458
|
+
// event (immediate) without needing a fourth vocabulary member.
|
|
459
|
+
deps.write({ type: "event", id: null, text, origin: null });
|
|
460
|
+
return null;
|
|
212
461
|
}
|
|
213
462
|
if (message.event !== "activity")
|
|
214
463
|
return null;
|
|
@@ -237,7 +486,13 @@ export async function runListener(options, deps) {
|
|
|
237
486
|
// DX-2735: oneLine caps by code point, so a bad event carrying an emoji
|
|
238
487
|
// at the cut can never leave a lone surrogate in the line.
|
|
239
488
|
oneLine(message.data, 300);
|
|
240
|
-
|
|
489
|
+
// DX-2730 D4 — an event this listener could not parse carries `origin: null`
|
|
490
|
+
// (emit immediately) rather than the unreadable payload's own claimed
|
|
491
|
+
// origin: a fault worth surfacing at all must never be hidden behind a
|
|
492
|
+
// 10-minute digest wait just because it happened to come from an agent or
|
|
493
|
+
// a machine writer.
|
|
494
|
+
const origin = invalid === null ? parsed.origin : null;
|
|
495
|
+
deps.write({ type: "event", id: knownId ? id : null, text, origin });
|
|
241
496
|
if (knownId)
|
|
242
497
|
remember(id);
|
|
243
498
|
return null;
|
|
@@ -262,11 +517,29 @@ export async function runListener(options, deps) {
|
|
|
262
517
|
armIdle();
|
|
263
518
|
const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
|
|
264
519
|
if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
|
|
265
|
-
|
|
520
|
+
const text = await response.text();
|
|
521
|
+
// DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
|
|
522
|
+
// classify by status + error code (`bridge.ts` does, matching a specific
|
|
523
|
+
// admission refusal such as the dashboard's own `409 session_not_connected`)
|
|
524
|
+
// rather than re-deriving it from the flattened `detail` string below.
|
|
525
|
+
let body = null;
|
|
526
|
+
try {
|
|
527
|
+
body = text === "" ? null : JSON.parse(text);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
body = null;
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
kind: "refused",
|
|
534
|
+
status: response.status,
|
|
535
|
+
errorCode: errorCodeOf(body),
|
|
536
|
+
body,
|
|
537
|
+
detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
|
|
538
|
+
};
|
|
266
539
|
}
|
|
267
540
|
if (!response.ok || response.body === null) {
|
|
268
541
|
await response.body?.cancel();
|
|
269
|
-
return { kind: "dropped", healthy: false };
|
|
542
|
+
return { kind: "dropped", healthy: false, detail: `HTTP ${response.status} with no readable body` };
|
|
270
543
|
}
|
|
271
544
|
const parser = new SseParser();
|
|
272
545
|
const decoder = new TextDecoder();
|
|
@@ -282,8 +555,14 @@ export async function runListener(options, deps) {
|
|
|
282
555
|
}
|
|
283
556
|
return { kind: "dropped", healthy: healthy() };
|
|
284
557
|
}
|
|
285
|
-
catch {
|
|
286
|
-
|
|
558
|
+
catch (err) {
|
|
559
|
+
// DX-2829 — was a bare `catch { ... }` that discarded the causing error
|
|
560
|
+
// entirely: a connection that failed the same way on every retry (DNS
|
|
561
|
+
// failure, TLS reset, the idle-timeout abort) surfaced nothing beyond
|
|
562
|
+
// "no healthy connection" once the lease finally expired. Preserving the
|
|
563
|
+
// message here is what lets `runListener` name the actual last failure
|
|
564
|
+
// in its final `lease_expired` stop record below.
|
|
565
|
+
return { kind: "dropped", healthy: healthy(), detail: describeDropError(err) };
|
|
287
566
|
}
|
|
288
567
|
finally {
|
|
289
568
|
clearTimeout(idle);
|
|
@@ -296,11 +575,26 @@ export async function runListener(options, deps) {
|
|
|
296
575
|
if (outcome.reason === "superseded" || outcome.reason === "replaced") {
|
|
297
576
|
return stop(outcome.reason, "another listener for this session took over", 0);
|
|
298
577
|
}
|
|
578
|
+
if (outcome.reason === "bad_end_payload") {
|
|
579
|
+
return stop("bad_end_payload", "the dashboard's scope_narrowed end event carried no valid boards array — this is a protocol error, not an ordinary end", 1);
|
|
580
|
+
}
|
|
581
|
+
// DX-2920 (round 4) — split explicitly rather than the old conditional
|
|
582
|
+
// `outcome.boards === undefined ? undefined : {boards}` pass-through:
|
|
583
|
+
// `Outcome`'s "ended" kind is now itself discriminated on `reason`, so
|
|
584
|
+
// `outcome.boards` is GUARANTEED present here (no `?? []`, no optional
|
|
585
|
+
// check needed) once narrowed to `"scope_narrowed"`.
|
|
586
|
+
if (outcome.reason === "scope_narrowed") {
|
|
587
|
+
return stop("scope_narrowed", "the dashboard ended this listener", 1, { boards: outcome.boards });
|
|
588
|
+
}
|
|
299
589
|
return stop(outcome.reason, "the dashboard ended this listener", 1);
|
|
300
590
|
}
|
|
301
591
|
if (outcome.kind === "refused") {
|
|
302
|
-
return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1
|
|
592
|
+
return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1, {
|
|
593
|
+
refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
|
|
594
|
+
});
|
|
303
595
|
}
|
|
596
|
+
if (outcome.detail !== undefined)
|
|
597
|
+
lastDropDetail = outcome.detail;
|
|
304
598
|
if (outcome.healthy) {
|
|
305
599
|
unhealthySince = null;
|
|
306
600
|
attempt = 0;
|
|
@@ -308,7 +602,10 @@ export async function runListener(options, deps) {
|
|
|
308
602
|
const now = deps.now();
|
|
309
603
|
unhealthySince ??= now;
|
|
310
604
|
if (now - unhealthySince >= options.leaseMs) {
|
|
311
|
-
|
|
605
|
+
// DX-2829 — names the last connection failure when one was ever recorded,
|
|
606
|
+
// instead of only reporting the timeout itself.
|
|
607
|
+
const cause = lastDropDetail === null ? "" : ` — last connection attempt failed: ${lastDropDetail}`;
|
|
608
|
+
return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease${cause}`, 1);
|
|
312
609
|
}
|
|
313
610
|
await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
|
|
314
611
|
attempt += 1;
|
package/dist/one-line.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2735 — the ONE way this package renders human text into an agent-facing
|
|
3
|
-
* line: the
|
|
4
|
-
* through it, so they can never disagree about how a
|
|
3
|
+
* line: the dashboard-event stream's notification line and the problems
|
|
4
|
+
* reminder both go through it, so they can never disagree about how a
|
|
5
|
+
* statement looks.
|
|
5
6
|
*
|
|
6
7
|
* Every whitespace run (newlines included) collapses to a single space, because
|
|
7
8
|
* a notification is one line and a reminder quotes statements inline. The
|