@thehammer/danx-dashboard-mcp 0.1.334 → 0.1.336
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/heartbeat-reporter.js +28 -17
- package/dist/index.js +3 -1
- package/dist/plan-events-relay.js +34 -9
- package/dist/session-stats.js +64 -21
- package/dist/shutdown.js +16 -3
- package/package.json +1 -1
|
@@ -13,6 +13,11 @@ import { DIGEST_PREFIX } from "./digest-hold.js";
|
|
|
13
13
|
import { HEARTBEAT_SILENT_FAILURE_THRESHOLD, HEARTBEAT_SUSTAINED_OUTAGE_MS, sendHeartbeat, } from "./session-stream.js";
|
|
14
14
|
import { OperatorSessionStats } from "./session-stats.js";
|
|
15
15
|
import { TranscriptTitleReader } from "./session-title.js";
|
|
16
|
+
/** DX-5415: how long a stop waits for the final settle (drain + one stats flush) before giving up; shutdown must stay bounded. */
|
|
17
|
+
export const FINAL_SETTLE_BOUND_MS = 10_000;
|
|
18
|
+
function settleBound() {
|
|
19
|
+
return AbortSignal.timeout(FINAL_SETTLE_BOUND_MS);
|
|
20
|
+
}
|
|
16
21
|
export class HeartbeatReporter {
|
|
17
22
|
deps;
|
|
18
23
|
sessionStats;
|
|
@@ -42,21 +47,38 @@ export class HeartbeatReporter {
|
|
|
42
47
|
*/
|
|
43
48
|
useTranscript(path) {
|
|
44
49
|
if (path === undefined || path === this.transcriptPath)
|
|
45
|
-
return;
|
|
46
|
-
this.sessionStats
|
|
50
|
+
return null;
|
|
51
|
+
const previous = this.sessionStats;
|
|
47
52
|
this.transcriptPath = path;
|
|
48
53
|
this.sessionStats = new OperatorSessionStats(path, this.deps.makeStatsWatcher);
|
|
49
54
|
this.titleReader = new TranscriptTitleReader(path);
|
|
50
55
|
this.dashboardTitle = undefined;
|
|
51
56
|
this.lastTitleError = null;
|
|
57
|
+
// DX-5415: the old transcript's held finals are stored before this returns; the swap above is synchronous so a second call sees the new path.
|
|
58
|
+
return previous.stop(this.send(undefined), settleBound());
|
|
52
59
|
}
|
|
53
60
|
/** A fresh connect cycle earns a clean slate for the silent-failure count. */
|
|
54
61
|
resetFailureStreak() {
|
|
55
62
|
this.failureStreak = 0;
|
|
56
63
|
}
|
|
57
|
-
/**
|
|
58
|
-
stop() {
|
|
59
|
-
this.sessionStats.stop();
|
|
64
|
+
/** DX-5415: settle the collector's held sub-agent finals (bounded), then stop tailing the transcript. Idempotent. */
|
|
65
|
+
async stop() {
|
|
66
|
+
await this.sessionStats.stop(this.send(undefined), settleBound());
|
|
67
|
+
}
|
|
68
|
+
/** One heartbeat as the reporter sends it: the carried answer's title and revocation are applied here. */
|
|
69
|
+
send(title) {
|
|
70
|
+
return async (stats) => {
|
|
71
|
+
const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title);
|
|
72
|
+
if (answer.landed)
|
|
73
|
+
this.dashboardTitle = answer.title ?? undefined;
|
|
74
|
+
// DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
|
|
75
|
+
// dashboard call says the same.
|
|
76
|
+
if (answer.revocation !== null && !this.revocationAnnounced) {
|
|
77
|
+
this.revocationAnnounced = true;
|
|
78
|
+
this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
|
|
79
|
+
}
|
|
80
|
+
return answer;
|
|
81
|
+
};
|
|
60
82
|
}
|
|
61
83
|
titleToReport() {
|
|
62
84
|
if (this.titleReader === null || this.dashboardTitle === undefined)
|
|
@@ -80,18 +102,7 @@ export class HeartbeatReporter {
|
|
|
80
102
|
tick() {
|
|
81
103
|
const title = this.titleToReport();
|
|
82
104
|
void this.sessionStats
|
|
83
|
-
.tick(
|
|
84
|
-
const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title);
|
|
85
|
-
if (answer.landed)
|
|
86
|
-
this.dashboardTitle = answer.title ?? undefined;
|
|
87
|
-
// DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
|
|
88
|
-
// dashboard call says the same.
|
|
89
|
-
if (answer.revocation !== null && !this.revocationAnnounced) {
|
|
90
|
-
this.revocationAnnounced = true;
|
|
91
|
-
this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
|
|
92
|
-
}
|
|
93
|
-
return answer;
|
|
94
|
-
})
|
|
105
|
+
.tick(this.send(title))
|
|
95
106
|
.then((landed) => {
|
|
96
107
|
const now = this.deps.now();
|
|
97
108
|
if (landed) {
|
package/dist/index.js
CHANGED
|
@@ -866,9 +866,11 @@ async function main() {
|
|
|
866
866
|
stopWhenSessionEnds({
|
|
867
867
|
server: server.server,
|
|
868
868
|
stdin: process.stdin,
|
|
869
|
+
signals: process,
|
|
870
|
+
exit: (code) => process.exit(code),
|
|
869
871
|
stop: () => {
|
|
870
872
|
permissionWatch.stop();
|
|
871
|
-
planEventRelay.close();
|
|
873
|
+
return planEventRelay.close();
|
|
872
874
|
},
|
|
873
875
|
});
|
|
874
876
|
const transport = new StdioServerTransport();
|
|
@@ -115,6 +115,10 @@ export class PlanEventRelay {
|
|
|
115
115
|
lastActivityAt = 0;
|
|
116
116
|
heartbeat = null;
|
|
117
117
|
reporter = null;
|
|
118
|
+
/** DX-5415: the stop of the last suspended reporter (its final settle); resolved when none is running. */
|
|
119
|
+
settling = null;
|
|
120
|
+
/** DX-5415: set by `close()` (the server's shutdown) and never cleared: a call paused across the close, or made after it, answers empty and starts nothing. */
|
|
121
|
+
closed = false;
|
|
118
122
|
digest = new DigestHold((event) => this.accept(event));
|
|
119
123
|
constructor(deps) {
|
|
120
124
|
this.deps = deps;
|
|
@@ -124,8 +128,14 @@ export class PlanEventRelay {
|
|
|
124
128
|
// Parsed first: a cursor this server did not issue is refused before it changes anything.
|
|
125
129
|
const cursor = args.cursor === null ? null : parseCursor(args.cursor);
|
|
126
130
|
// DX-4233: a request already cancelled (a plugin reload) touches nothing and starts nothing.
|
|
127
|
-
if (signal?.aborted)
|
|
131
|
+
if (signal?.aborted || this.closed)
|
|
128
132
|
return { events: [] };
|
|
133
|
+
// DX-5415: a suspend still storing the old collector's held finals ends before this call starts a new one (bounded inside).
|
|
134
|
+
if (this.settling !== null) {
|
|
135
|
+
await this.settling;
|
|
136
|
+
if (signal?.aborted || this.closed)
|
|
137
|
+
return { events: [] };
|
|
138
|
+
}
|
|
129
139
|
// DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
|
|
130
140
|
this.supersedeWaiter();
|
|
131
141
|
// DX-4233: a plan move starts clean (the very first call has no last plan; after a suspend the last plan is still known, and `dropPlanScoped` already ran).
|
|
@@ -144,7 +154,13 @@ export class PlanEventRelay {
|
|
|
144
154
|
this.buffer = this.buffer.map((r) => ({ ...r, recent: boundedIds([...cursor.ids, ...r.recent]) }));
|
|
145
155
|
}
|
|
146
156
|
this.touch();
|
|
147
|
-
this.ensureReporter(args.transcriptPath);
|
|
157
|
+
const switching = this.ensureReporter(args.transcriptPath);
|
|
158
|
+
if (switching !== null) {
|
|
159
|
+
await switching;
|
|
160
|
+
// DX-5415 / DX-4233: the pause let a close() or a cancel through; a close already let go of the stream, heartbeat and reporter, so none is started again.
|
|
161
|
+
if (signal?.aborted || this.closed)
|
|
162
|
+
return { events: [] };
|
|
163
|
+
}
|
|
148
164
|
this.buffer = this.buffer.filter((r) => !acknowledged(r.seq, cursor));
|
|
149
165
|
if (this.pendingStop !== null) {
|
|
150
166
|
const stopped = this.pendingStop;
|
|
@@ -175,8 +191,9 @@ export class PlanEventRelay {
|
|
|
175
191
|
}
|
|
176
192
|
/** Stop everything: the pending call, the stream, the heartbeat, the transcript tailer. For the server's shutdown. */
|
|
177
193
|
close() {
|
|
194
|
+
this.closed = true;
|
|
178
195
|
this.supersedeWaiter();
|
|
179
|
-
this.suspend();
|
|
196
|
+
return this.suspend();
|
|
180
197
|
}
|
|
181
198
|
// ---------------------------------------------------------------------------------------------------------------- calls
|
|
182
199
|
finishCall(waiter, answer) {
|
|
@@ -267,10 +284,8 @@ export class PlanEventRelay {
|
|
|
267
284
|
}
|
|
268
285
|
/** Creates the heartbeat reporter on the first call; a later call's transcript path (a resumed session) is handed to it. */
|
|
269
286
|
ensureReporter(transcriptPath) {
|
|
270
|
-
if (this.reporter !== null)
|
|
271
|
-
this.reporter.useTranscript(transcriptPath);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
287
|
+
if (this.reporter !== null)
|
|
288
|
+
return this.reporter.useTranscript(transcriptPath);
|
|
274
289
|
this.reporter = new HeartbeatReporter({
|
|
275
290
|
auth: () => {
|
|
276
291
|
const auth = this.deps.auth();
|
|
@@ -287,6 +302,7 @@ export class PlanEventRelay {
|
|
|
287
302
|
announce: (text, source) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session", urgent: isUrgent({ source }) }),
|
|
288
303
|
forceReconnect: () => this.run?.forceReconnect?.(),
|
|
289
304
|
}, transcriptPath ?? null);
|
|
305
|
+
return null;
|
|
290
306
|
}
|
|
291
307
|
/** Mark the relay in use and make sure the heartbeat is scheduled. */
|
|
292
308
|
touch() {
|
|
@@ -298,7 +314,7 @@ export class PlanEventRelay {
|
|
|
298
314
|
if (this.waiter === null && this.deps.now() - this.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) {
|
|
299
315
|
// Nobody is reading this session's events any more: stop heartbeating (the dashboard then reads the listener not
|
|
300
316
|
// healthy) and let go of the stream. The next call starts it again from its cursor; the dashboard replays what it missed.
|
|
301
|
-
this.suspend();
|
|
317
|
+
void this.suspend();
|
|
302
318
|
return;
|
|
303
319
|
}
|
|
304
320
|
const auth = this.deps.auth();
|
|
@@ -328,7 +344,15 @@ export class PlanEventRelay {
|
|
|
328
344
|
this.heartbeat = null;
|
|
329
345
|
this.run?.abort.abort();
|
|
330
346
|
this.run = null;
|
|
331
|
-
|
|
347
|
+
// DX-5415: the reporter's stop settles its collector's held sub-agent finals first; callers that can wait (shutdown, the next call) do.
|
|
348
|
+
const stopping = this.reporter?.stop() ?? null;
|
|
349
|
+
if (stopping !== null) {
|
|
350
|
+
const settling = Promise.all([this.settling, stopping]).then(() => {
|
|
351
|
+
if (this.settling === settling)
|
|
352
|
+
this.settling = null;
|
|
353
|
+
});
|
|
354
|
+
this.settling = settling;
|
|
355
|
+
}
|
|
332
356
|
this.reporter = null;
|
|
333
357
|
this.digest.reset();
|
|
334
358
|
this.dropPlanScoped();
|
|
@@ -339,6 +363,7 @@ export class PlanEventRelay {
|
|
|
339
363
|
this.connectionState = "reconnecting";
|
|
340
364
|
this.stoppedReason = null;
|
|
341
365
|
this.lastInventoryError = null;
|
|
366
|
+
return this.settling ?? Promise.resolve();
|
|
342
367
|
}
|
|
343
368
|
// ------------------------------------------------------------------------------------------------- the mint / stream loop
|
|
344
369
|
start(cursor) {
|
package/dist/session-stats.js
CHANGED
|
@@ -85,19 +85,32 @@ export class OperatorSessionStats {
|
|
|
85
85
|
this.begin(answer.stats);
|
|
86
86
|
return answer.landed;
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
const { carried, landed, planless } = await this.flushVia(send);
|
|
89
|
+
if (planless) {
|
|
90
|
+
// DX-4892: re-queueing would resend this batch on every tick for as long as the session stays planless. Drop the collector instead:
|
|
91
|
+
// the plain heartbeats that follow start collection again from the stored cursors once an answer carries stats (the session is bound).
|
|
92
|
+
this.watcher?.stop();
|
|
93
|
+
this.watcher = null;
|
|
94
|
+
this.usage = newCollector();
|
|
95
|
+
return landed;
|
|
96
|
+
}
|
|
97
|
+
if (carried)
|
|
98
|
+
return landed;
|
|
99
|
+
return (await send(undefined)).landed;
|
|
100
|
+
}
|
|
101
|
+
/** One collector flush carried by `send`; a refused or unlanded batch throws inside the collector, which re-queues it. */
|
|
102
|
+
async flushVia(send) {
|
|
103
|
+
const outcome = { carried: false, landed: true, planless: false };
|
|
104
|
+
const flushed = await this.usage.flush({
|
|
92
105
|
applyStats: async (_dispatchId, payload) => {
|
|
93
|
-
carried = true;
|
|
106
|
+
outcome.carried = true;
|
|
94
107
|
const answer = await send(payload);
|
|
95
108
|
if (!answer.landed) {
|
|
96
|
-
landed = false;
|
|
109
|
+
outcome.landed = false;
|
|
97
110
|
throw new Error("stats heartbeat did not reach the dashboard");
|
|
98
111
|
}
|
|
99
112
|
if (answer.statsNotStored === "no_plan") {
|
|
100
|
-
planless = true;
|
|
113
|
+
outcome.planless = true;
|
|
101
114
|
throw new Error("stats heartbeat refused: the session is on no plan, so the dashboard stores no stats for it");
|
|
102
115
|
}
|
|
103
116
|
if (answer.stats === null) {
|
|
@@ -106,22 +119,52 @@ export class OperatorSessionStats {
|
|
|
106
119
|
return answer.stats;
|
|
107
120
|
},
|
|
108
121
|
}, OPERATOR_DISPATCH_REF);
|
|
109
|
-
|
|
110
|
-
// DX-4892: re-queueing would resend this batch on every tick for as long as the session stays planless. Drop the collector instead:
|
|
111
|
-
// the plain heartbeats that follow start collection again from the stored cursors once an answer carries stats (the session is bound).
|
|
112
|
-
this.watcher?.stop();
|
|
113
|
-
this.watcher = null;
|
|
114
|
-
this.usage = newCollector();
|
|
115
|
-
return landed;
|
|
116
|
-
}
|
|
117
|
-
if (carried)
|
|
118
|
-
return landed;
|
|
119
|
-
return (await send(undefined)).landed;
|
|
122
|
+
return { ...outcome, outcome: flushed };
|
|
120
123
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Stop tailing. Idempotent; a stopped collector never starts again.
|
|
126
|
+
*
|
|
127
|
+
* DX-5415: a sub-agent's final report is held until its end signal is read, and confirmed only by a LATER poll (DX-5413), so a bare
|
|
128
|
+
* `watcher.stop()` lost any final still held or whose signal was read in the last poll. A collecting watcher is therefore settled first:
|
|
129
|
+
* `drainFinal()`, then one flush carried by `send`, and only then stopped. The bound only stops WAITING: when it fires, `stop` returns
|
|
130
|
+
* and stops the watcher, but the drain and the flush already under way keep running to their own end (each dashboard request is
|
|
131
|
+
* bounded by its own request timeout), so shutdown never hangs and a cut-off settle can still land late. A drain that throws, a flush
|
|
132
|
+
* that fails or is refused, or a settle cut off costs only what it had not yet delivered, said on stderr.
|
|
133
|
+
*/
|
|
134
|
+
async stop(send, bound) {
|
|
135
|
+
if (this.stopped)
|
|
136
|
+
return;
|
|
123
137
|
this.stopped = true;
|
|
124
|
-
this.watcher
|
|
138
|
+
const watcher = this.watcher;
|
|
139
|
+
if (watcher === null)
|
|
140
|
+
return;
|
|
141
|
+
const cutOff = new Promise((resolve) => {
|
|
142
|
+
if (bound.aborted)
|
|
143
|
+
resolve("cut");
|
|
144
|
+
else
|
|
145
|
+
bound.addEventListener("abort", () => resolve("cut"), { once: true });
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
const settled = (async () => {
|
|
149
|
+
await watcher.drainFinal();
|
|
150
|
+
// DX-5415: a failed or refused flush re-queues nothing for a collector that is stopping, so it says what it costs.
|
|
151
|
+
const flushed = (await this.flushVia(send)).outcome;
|
|
152
|
+
if (flushed !== "ok")
|
|
153
|
+
process.stderr.write(`operator session stats: the final flush was ${flushed}; held sub-agent finals may be lost
|
|
154
|
+
`);
|
|
155
|
+
return "settled";
|
|
156
|
+
})();
|
|
157
|
+
settled.catch(() => undefined); // a cut-off settle's late failure is already reported by the race below
|
|
158
|
+
if ((await Promise.race([settled, cutOff])) === "cut") {
|
|
159
|
+
process.stderr.write("operator session stats: the final settle was cut off by its bound; held sub-agent finals may be lost\n");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
process.stderr.write(`operator session stats: final settle failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
watcher.stop();
|
|
167
|
+
}
|
|
125
168
|
}
|
|
126
169
|
begin(stored) {
|
|
127
170
|
// `this.watcher !== null`: the relay fires `tick` without awaiting it, so
|
package/dist/shutdown.js
CHANGED
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
* DX-4523 / DX-4233 - what the server lets go of when its session ends: the permission watch's open stream and the plan event
|
|
3
3
|
* relay (its pending call, stream, heartbeat and tailer), so nothing keeps the process alive after the client is gone.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* transport does not watch stdin for its end
|
|
7
|
-
*
|
|
5
|
+
* Three triggers, all needed: the transport's `onclose` (an orderly close of the connection), the end of stdin (the SDK's stdio
|
|
6
|
+
* transport does not watch stdin for its end, so a client that simply exits would leave the process running without it), and a
|
|
7
|
+
* SIGTERM / SIGINT.
|
|
8
|
+
*
|
|
9
|
+
* DX-5415: `stop` returns the relay's bounded final settle (a sub-agent's held final report is stored before the collector stops).
|
|
10
|
+
* The first two triggers leave the process to end on its own once nothing keeps it alive, so the settle completes. A signal would
|
|
11
|
+
* kill it mid-settle, so its handler awaits `stop` and only then exits. `once`: a second signal finds no listener and takes the
|
|
12
|
+
* default action, the way out when the settle is slow.
|
|
8
13
|
*/
|
|
9
14
|
export function stopWhenSessionEnds(wiring) {
|
|
10
15
|
wiring.server.onclose = wiring.stop;
|
|
11
16
|
wiring.stdin.once("end", wiring.stop);
|
|
17
|
+
const exitAfterStop = () => {
|
|
18
|
+
void Promise.resolve(wiring.stop()).then(() => wiring.exit(0), (err) => {
|
|
19
|
+
process.stderr.write(`danx-dashboard-mcp: shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
20
|
+
wiring.exit(1);
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
wiring.signals.once("SIGTERM", exitAfterStop);
|
|
24
|
+
wiring.signals.once("SIGINT", exitAfterStop);
|
|
12
25
|
}
|
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.336",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|