@exaudeus/workrail 3.26.1 → 3.27.0
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/cli.js +22 -0
- package/dist/console/assets/{index-HhtarvD5.js → index-FtTaDku8.js} +1 -1
- package/dist/console/index.html +1 -1
- package/dist/daemon/workflow-runner.d.ts +2 -0
- package/dist/daemon/workflow-runner.js +51 -4
- package/dist/infrastructure/session/HttpServer.js +66 -14
- package/dist/manifest.json +47 -31
- package/dist/mcp/server.js +34 -18
- package/dist/mcp/transports/bridge-entry.d.ts +33 -1
- package/dist/mcp/transports/bridge-entry.js +159 -10
- package/dist/mcp/transports/bridge-events.d.ts +17 -0
- package/dist/mcp/transports/fatal-exit.d.ts +1 -0
- package/dist/mcp/transports/fatal-exit.js +27 -1
- package/dist/mcp/transports/http-entry.js +10 -1
- package/dist/mcp/transports/primary-tombstone.d.ts +21 -0
- package/dist/mcp/transports/primary-tombstone.js +51 -0
- package/dist/mcp/transports/shutdown-hooks.js +20 -5
- package/dist/mcp/transports/stdio-entry.js +7 -0
- package/dist/mcp-server.js +5 -5
- package/dist/trigger/daemon-console.d.ts +24 -0
- package/dist/trigger/daemon-console.js +120 -0
- package/package.json +1 -1
|
@@ -35,17 +35,26 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.DEFAULT_BRIDGE_CONFIG = void 0;
|
|
37
37
|
exports.detectHealthyPrimary = detectHealthyPrimary;
|
|
38
|
+
exports.spawnLockPath = spawnLockPath;
|
|
39
|
+
exports.acquireSpawnLock = acquireSpawnLock;
|
|
40
|
+
exports.releaseSpawnLock = releaseSpawnLock;
|
|
38
41
|
exports.spawnPrimary = spawnPrimary;
|
|
39
42
|
exports.reconnectWithBackoff = reconnectWithBackoff;
|
|
40
43
|
exports.handleReconnectOutcome = handleReconnectOutcome;
|
|
41
44
|
exports.startBridgeServer = startBridgeServer;
|
|
42
45
|
const fatal_exit_js_1 = require("./fatal-exit.js");
|
|
43
46
|
const bridge_events_js_1 = require("./bridge-events.js");
|
|
47
|
+
const primary_tombstone_js_1 = require("./primary-tombstone.js");
|
|
48
|
+
const fs_1 = require("fs");
|
|
49
|
+
const os_1 = require("os");
|
|
50
|
+
const path_1 = require("path");
|
|
44
51
|
exports.DEFAULT_BRIDGE_CONFIG = {
|
|
45
52
|
reconnectBaseDelayMs: 250,
|
|
46
53
|
reconnectMaxAttempts: 8,
|
|
47
54
|
forwardTimeoutMs: 30000,
|
|
48
55
|
maxRespawnAttempts: 3,
|
|
56
|
+
spawnLockStaleMs: 30000,
|
|
57
|
+
waitForPrimaryPollMs: 5000,
|
|
49
58
|
};
|
|
50
59
|
async function detectHealthyPrimary(port, opts = {}) {
|
|
51
60
|
const retries = opts.retries ?? 3;
|
|
@@ -59,8 +68,10 @@ async function detectHealthyPrimary(port, opts = {}) {
|
|
|
59
68
|
});
|
|
60
69
|
if (response.ok) {
|
|
61
70
|
const body = (await response.json().catch(() => null));
|
|
62
|
-
if (body?.service === 'workrail')
|
|
63
|
-
|
|
71
|
+
if (body?.service === 'workrail') {
|
|
72
|
+
const pid = typeof body.pid === 'number' ? body.pid : 0;
|
|
73
|
+
return { port, pid };
|
|
74
|
+
}
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
catch {
|
|
@@ -71,10 +82,71 @@ async function detectHealthyPrimary(port, opts = {}) {
|
|
|
71
82
|
}
|
|
72
83
|
return null;
|
|
73
84
|
}
|
|
85
|
+
function spawnLockPath(port) {
|
|
86
|
+
return (0, path_1.join)((0, os_1.homedir)(), '.workrail', `spawn-coordinator-${port}.lock`);
|
|
87
|
+
}
|
|
88
|
+
function acquireSpawnLock(port, staleMs, deps = {}) {
|
|
89
|
+
const lockPath = spawnLockPath(port);
|
|
90
|
+
const writeFn = deps.writeFileSync ?? fs_1.writeFileSync;
|
|
91
|
+
const statFn = deps.statSync ?? require('fs').statSync;
|
|
92
|
+
const unlinkFn = deps.unlinkSync ?? require('fs').unlinkSync;
|
|
93
|
+
try {
|
|
94
|
+
(0, fs_1.mkdirSync)((0, path_1.join)((0, os_1.homedir)(), '.workrail'), { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { kind: 'skipped', reason: 'mkdirSync failed' };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
writeFn(lockPath, String(process.pid), { flag: 'wx' });
|
|
101
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_acquired', port });
|
|
102
|
+
return { kind: 'acquired' };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const code = err.code;
|
|
106
|
+
if (code === 'EEXIST') {
|
|
107
|
+
try {
|
|
108
|
+
const stat = statFn(lockPath);
|
|
109
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
110
|
+
if (ageMs > staleMs) {
|
|
111
|
+
try {
|
|
112
|
+
unlinkFn(lockPath);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
writeFn(lockPath, String(process.pid), { flag: 'wx' });
|
|
118
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_acquired', port });
|
|
119
|
+
return { kind: 'acquired' };
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: 'lost race after stale reclaim' });
|
|
123
|
+
return { kind: 'skipped', reason: 'lost race after stale reclaim' };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: 'lock held by another bridge' });
|
|
127
|
+
return { kind: 'skipped', reason: 'lock held by another bridge' };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: 'lock contested' });
|
|
131
|
+
return { kind: 'skipped', reason: 'lock contested' };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: `unexpected error: ${code ?? String(err)}` });
|
|
135
|
+
return { kind: 'skipped', reason: `unexpected error: ${code ?? String(err)}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function releaseSpawnLock(port, deps = {}) {
|
|
139
|
+
const unlinkFn = deps.unlinkSync ?? require('fs').unlinkSync;
|
|
140
|
+
try {
|
|
141
|
+
unlinkFn(spawnLockPath(port));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
}
|
|
145
|
+
}
|
|
74
146
|
async function spawnPrimary(port, deps) {
|
|
75
|
-
await sleep(Math.random() *
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
147
|
+
await sleep(Math.random() * 2000);
|
|
148
|
+
const primaryDetected = await detectHealthyPrimary(port, { retries: 3, baseDelayMs: 500, fetch: deps.fetch });
|
|
149
|
+
if (primaryDetected != null) {
|
|
78
150
|
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_skipped', reason: 'primary already up after jitter' });
|
|
79
151
|
console.error('[Bridge] Primary already available after jitter — skipping spawn');
|
|
80
152
|
return;
|
|
@@ -140,9 +212,14 @@ async function handleReconnectOutcome(outcome, reconnectingState, deps) {
|
|
|
140
212
|
deps.startReconnectLoop();
|
|
141
213
|
}
|
|
142
214
|
else {
|
|
143
|
-
(0, bridge_events_js_1.logBridgeEvent)({
|
|
144
|
-
|
|
145
|
-
|
|
215
|
+
(0, bridge_events_js_1.logBridgeEvent)({
|
|
216
|
+
kind: 'budget_exhausted',
|
|
217
|
+
budgetUsed: reconnectingState.maxAttempts,
|
|
218
|
+
respawnBudget: reconnectingState.respawnBudget,
|
|
219
|
+
});
|
|
220
|
+
console.error('[Bridge] Spawn budget exhausted — entering slow-poll mode (5s interval)');
|
|
221
|
+
deps.setConnectionState({ kind: 'waiting_for_primary' });
|
|
222
|
+
deps.startWaitLoop();
|
|
146
223
|
}
|
|
147
224
|
return;
|
|
148
225
|
}
|
|
@@ -183,10 +260,23 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
|
|
|
183
260
|
if (shutdownSignal.aborted)
|
|
184
261
|
return;
|
|
185
262
|
const current = connectionState;
|
|
186
|
-
if (current.kind === 'connecting' ||
|
|
263
|
+
if (current.kind === 'connecting' ||
|
|
264
|
+
current.kind === 'reconnecting' ||
|
|
265
|
+
current.kind === 'waiting_for_primary')
|
|
187
266
|
return;
|
|
188
267
|
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'disconnected' });
|
|
189
268
|
console.error('[Bridge] Primary connection lost — reconnecting');
|
|
269
|
+
const connectedPrimaryPid = deps.originalPrimaryPid ?? 0;
|
|
270
|
+
if (connectedPrimaryPid > 0) {
|
|
271
|
+
const tombstone = (0, primary_tombstone_js_1.readTombstone)();
|
|
272
|
+
if (tombstone?.pid === connectedPrimaryPid) {
|
|
273
|
+
console.error('[Bridge] Tombstone detected — primary died cleanly, entering slow-poll mode');
|
|
274
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'waiting_for_primary', port: primaryPort });
|
|
275
|
+
setConnectionState({ kind: 'waiting_for_primary' });
|
|
276
|
+
startWaitLoop();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
190
280
|
setConnectionState({
|
|
191
281
|
kind: 'reconnecting',
|
|
192
282
|
attempt: 0,
|
|
@@ -213,6 +303,17 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
|
|
|
213
303
|
signal: shutdownSignal,
|
|
214
304
|
config,
|
|
215
305
|
detect: async (attempt) => {
|
|
306
|
+
const connectedPrimaryPid = deps.originalPrimaryPid ?? 0;
|
|
307
|
+
if (connectedPrimaryPid > 0) {
|
|
308
|
+
const tombstone = (0, primary_tombstone_js_1.readTombstone)();
|
|
309
|
+
if (tombstone?.pid === connectedPrimaryPid) {
|
|
310
|
+
console.error('[Bridge] Tombstone detected during reconnect — entering slow-poll mode');
|
|
311
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'waiting_for_primary', port: primaryPort });
|
|
312
|
+
setConnectionState({ kind: 'waiting_for_primary' });
|
|
313
|
+
startWaitLoop();
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
216
317
|
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'reconnect_attempt', attempt: attempt + 1, maxAttempts: config.reconnectMaxAttempts });
|
|
217
318
|
console.error(`[Bridge] Reconnect attempt ${attempt + 1}/${config.reconnectMaxAttempts}`);
|
|
218
319
|
const detected = await detectHealthyPrimary(primaryPort, { retries: 1, fetch: deps.fetch });
|
|
@@ -230,7 +331,20 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
|
|
|
230
331
|
setConnectionState,
|
|
231
332
|
performShutdown,
|
|
232
333
|
startReconnectLoop,
|
|
233
|
-
|
|
334
|
+
startWaitLoop,
|
|
335
|
+
triggerSpawn: async () => {
|
|
336
|
+
const lockResult = acquireSpawnLock(primaryPort, config.spawnLockStaleMs);
|
|
337
|
+
if (lockResult.kind === 'skipped') {
|
|
338
|
+
console.error(`[Bridge] Spawn skipped (coordinator lock): ${lockResult.reason}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
await spawnPrimary(primaryPort, { spawn: spawnFn, fetch: deps.fetch });
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
releaseSpawnLock(primaryPort);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
234
348
|
config,
|
|
235
349
|
});
|
|
236
350
|
})
|
|
@@ -244,6 +358,40 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
|
|
|
244
358
|
console.error('[Bridge] Unexpected error in reconnect loop:', err);
|
|
245
359
|
});
|
|
246
360
|
};
|
|
361
|
+
const startWaitLoop = () => {
|
|
362
|
+
const stateAtStart = connectionState;
|
|
363
|
+
if (stateAtStart.kind !== 'waiting_for_primary')
|
|
364
|
+
return;
|
|
365
|
+
void (async () => {
|
|
366
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'waiting_for_primary', port: primaryPort });
|
|
367
|
+
while (!shutdownSignal.aborted) {
|
|
368
|
+
await sleep(config.waitForPrimaryPollMs);
|
|
369
|
+
if (shutdownSignal.aborted)
|
|
370
|
+
return;
|
|
371
|
+
const detected = await detectHealthyPrimary(primaryPort, { retries: 1, fetch: deps.fetch });
|
|
372
|
+
if (detected != null) {
|
|
373
|
+
console.error('[Bridge] Primary found after waiting — resuming normal operation');
|
|
374
|
+
(0, bridge_events_js_1.logBridgeEvent)({ kind: 'primary_found_after_wait', port: primaryPort });
|
|
375
|
+
setConnectionState({
|
|
376
|
+
kind: 'reconnecting',
|
|
377
|
+
attempt: 0,
|
|
378
|
+
maxAttempts: config.reconnectMaxAttempts,
|
|
379
|
+
respawnBudget: config.maxRespawnAttempts,
|
|
380
|
+
});
|
|
381
|
+
startReconnectLoop();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
})().catch((err) => {
|
|
386
|
+
const errObj = err instanceof Error ? err : new Error(String(err));
|
|
387
|
+
(0, bridge_events_js_1.logBridgeEvent)({
|
|
388
|
+
kind: 'reconnect_loop_error',
|
|
389
|
+
message: `wait loop error: ${errObj.message}`,
|
|
390
|
+
stack: errObj.stack ?? null,
|
|
391
|
+
});
|
|
392
|
+
console.error('[Bridge] Unexpected error in wait loop:', err);
|
|
393
|
+
});
|
|
394
|
+
};
|
|
247
395
|
stdioTransport.onmessage = (msg) => {
|
|
248
396
|
const state = connectionState;
|
|
249
397
|
switch (state.kind) {
|
|
@@ -259,6 +407,7 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
|
|
|
259
407
|
}
|
|
260
408
|
case 'connecting':
|
|
261
409
|
case 'reconnecting':
|
|
410
|
+
case 'waiting_for_primary':
|
|
262
411
|
sendUnavailableError(msg, (m) => stdioTransport.send(m));
|
|
263
412
|
return;
|
|
264
413
|
case 'closed':
|
|
@@ -20,9 +20,22 @@ export type BridgeEvent = {
|
|
|
20
20
|
} | {
|
|
21
21
|
readonly kind: 'spawn_skipped';
|
|
22
22
|
readonly reason: string;
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: 'spawn_lock_acquired';
|
|
25
|
+
readonly port: number;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'spawn_lock_skipped';
|
|
28
|
+
readonly reason: string;
|
|
23
29
|
} | {
|
|
24
30
|
readonly kind: 'budget_exhausted';
|
|
25
31
|
readonly budgetUsed: number;
|
|
32
|
+
readonly respawnBudget: number;
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: 'waiting_for_primary';
|
|
35
|
+
readonly port: number;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'primary_found_after_wait';
|
|
38
|
+
readonly port: number;
|
|
26
39
|
} | {
|
|
27
40
|
readonly kind: 'reconnect_loop_error';
|
|
28
41
|
readonly message: string;
|
|
@@ -30,5 +43,9 @@ export type BridgeEvent = {
|
|
|
30
43
|
} | {
|
|
31
44
|
readonly kind: 'shutdown';
|
|
32
45
|
readonly reason: string;
|
|
46
|
+
} | {
|
|
47
|
+
readonly kind: 'orphaned';
|
|
48
|
+
readonly expectedPid: number;
|
|
49
|
+
readonly actualPid: number;
|
|
33
50
|
};
|
|
34
51
|
export declare function logBridgeEvent(event: BridgeEvent): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type TransportKind = 'stdio' | 'http' | 'bridge';
|
|
2
2
|
export declare function formatFatal(reason: unknown): string;
|
|
3
|
+
export declare function registerGracefulShutdown(fn: (() => Promise<void>) | null, timeoutMs?: number): void;
|
|
3
4
|
export declare function fatalExit(label: string, reason: unknown): void;
|
|
4
5
|
export declare function registerFatalHandlers(transport: TransportKind): void;
|
|
5
6
|
export declare function logStartup(transport: TransportKind, extra?: Record<string, string | number>): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatFatal = formatFatal;
|
|
4
|
+
exports.registerGracefulShutdown = registerGracefulShutdown;
|
|
4
5
|
exports.fatalExit = fatalExit;
|
|
5
6
|
exports.registerFatalHandlers = registerFatalHandlers;
|
|
6
7
|
exports.logStartup = logStartup;
|
|
@@ -10,6 +11,8 @@ const path_1 = require("path");
|
|
|
10
11
|
const fatalHandlerActive = { value: false };
|
|
11
12
|
let registeredTransport = null;
|
|
12
13
|
const startedAtMs = Date.now();
|
|
14
|
+
let gracefulShutdownFn = null;
|
|
15
|
+
let gracefulShutdownTimeoutMs = 3000;
|
|
13
16
|
const CRASH_LOG_PATH = (0, path_1.join)((0, os_1.homedir)(), '.workrail', 'crash.log');
|
|
14
17
|
const CRASH_LOG_MAX_BYTES = 512 * 1024;
|
|
15
18
|
function writeCrashLog(label, reason) {
|
|
@@ -46,6 +49,10 @@ function formatFatal(reason) {
|
|
|
46
49
|
}
|
|
47
50
|
return String(reason);
|
|
48
51
|
}
|
|
52
|
+
function registerGracefulShutdown(fn, timeoutMs = 3000) {
|
|
53
|
+
gracefulShutdownFn = fn;
|
|
54
|
+
gracefulShutdownTimeoutMs = timeoutMs;
|
|
55
|
+
}
|
|
49
56
|
function fatalExit(label, reason) {
|
|
50
57
|
if (fatalHandlerActive.value)
|
|
51
58
|
return;
|
|
@@ -56,7 +63,26 @@ function fatalExit(label, reason) {
|
|
|
56
63
|
}
|
|
57
64
|
catch {
|
|
58
65
|
}
|
|
59
|
-
|
|
66
|
+
if (gracefulShutdownFn !== null) {
|
|
67
|
+
const fn = gracefulShutdownFn;
|
|
68
|
+
const timeout = gracefulShutdownTimeoutMs;
|
|
69
|
+
try {
|
|
70
|
+
process.stderr.write(`[FatalExit] Attempting graceful shutdown (${timeout}ms timeout)\n`);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
}
|
|
74
|
+
const hardExit = setTimeout(() => process.exit(1), timeout);
|
|
75
|
+
void Promise.resolve()
|
|
76
|
+
.then(() => fn())
|
|
77
|
+
.catch(() => { })
|
|
78
|
+
.finally(() => {
|
|
79
|
+
clearTimeout(hardExit);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
60
86
|
}
|
|
61
87
|
function registerFatalHandlers(transport) {
|
|
62
88
|
registeredTransport = transport;
|
|
@@ -41,15 +41,21 @@ const server_js_1 = require("../server.js");
|
|
|
41
41
|
const http_listener_js_1 = require("./http-listener.js");
|
|
42
42
|
const shutdown_hooks_js_1 = require("./shutdown-hooks.js");
|
|
43
43
|
const fatal_exit_js_1 = require("./fatal-exit.js");
|
|
44
|
+
const primary_tombstone_js_1 = require("./primary-tombstone.js");
|
|
44
45
|
const crypto = __importStar(require("crypto"));
|
|
45
46
|
const express_1 = __importDefault(require("express"));
|
|
46
47
|
const HTTP_PORT_SCAN_END = 3199;
|
|
47
48
|
async function startHttpServer(port) {
|
|
48
49
|
(0, fatal_exit_js_1.registerFatalHandlers)('http');
|
|
49
50
|
(0, fatal_exit_js_1.logStartup)('http', { port });
|
|
51
|
+
(0, primary_tombstone_js_1.clearTombstone)();
|
|
50
52
|
const { server, ctx } = await (0, server_js_1.composeServer)();
|
|
51
53
|
const scanEnd = Math.max(port, HTTP_PORT_SCAN_END);
|
|
52
54
|
const listener = await (0, http_listener_js_1.bindWithPortFallback)(port, scanEnd);
|
|
55
|
+
(0, fatal_exit_js_1.registerGracefulShutdown)(async () => {
|
|
56
|
+
await listener.stop();
|
|
57
|
+
await ctx.httpServer?.stop();
|
|
58
|
+
});
|
|
53
59
|
const { StreamableHTTPServerTransport } = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/streamableHttp.js')));
|
|
54
60
|
const transport = new StreamableHTTPServerTransport({
|
|
55
61
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
@@ -61,13 +67,16 @@ async function startHttpServer(port) {
|
|
|
61
67
|
listener.app.delete('/mcp', (req, res) => transport.handleRequest(req, res));
|
|
62
68
|
await server.connect(transport);
|
|
63
69
|
listener.app.get('/workrail-health', (_req, res) => {
|
|
64
|
-
res.json({ service: 'workrail' });
|
|
70
|
+
res.json({ service: 'workrail', pid: process.pid });
|
|
65
71
|
});
|
|
66
72
|
const boundPort = listener.getBoundPort();
|
|
67
73
|
console.error('[Transport] WorkRail MCP Server running on HTTP');
|
|
68
74
|
console.error(`[Transport] MCP endpoint: http://localhost:${boundPort}/mcp`);
|
|
69
75
|
(0, shutdown_hooks_js_1.wireShutdownHooks)({
|
|
70
76
|
onBeforeTerminate: async () => {
|
|
77
|
+
if (boundPort != null) {
|
|
78
|
+
(0, primary_tombstone_js_1.writeTombstone)(boundPort, process.pid);
|
|
79
|
+
}
|
|
71
80
|
await listener.stop();
|
|
72
81
|
await ctx.httpServer?.stop();
|
|
73
82
|
},
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface PrimaryTombstone {
|
|
2
|
+
readonly pid: number;
|
|
3
|
+
readonly port: number;
|
|
4
|
+
readonly diedAt: string;
|
|
5
|
+
}
|
|
6
|
+
export type WriteSyncLike = (path: string, content: string) => void;
|
|
7
|
+
export type ReadSyncLike = (path: string, encoding: 'utf-8') => string;
|
|
8
|
+
export type UnlinkSyncLike = (path: string) => void;
|
|
9
|
+
export type MkdirSyncLike = (path: string, opts: {
|
|
10
|
+
recursive: true;
|
|
11
|
+
}) => void;
|
|
12
|
+
export interface TombstoneDeps {
|
|
13
|
+
readonly writeSync?: WriteSyncLike;
|
|
14
|
+
readonly readSync?: ReadSyncLike;
|
|
15
|
+
readonly unlinkSync?: UnlinkSyncLike;
|
|
16
|
+
readonly mkdirSync?: MkdirSyncLike;
|
|
17
|
+
}
|
|
18
|
+
export declare function tombstonePath(): string;
|
|
19
|
+
export declare function writeTombstone(port: number, pid: number, deps?: TombstoneDeps): void;
|
|
20
|
+
export declare function readTombstone(deps?: TombstoneDeps): PrimaryTombstone | null;
|
|
21
|
+
export declare function clearTombstone(deps?: TombstoneDeps): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tombstonePath = tombstonePath;
|
|
4
|
+
exports.writeTombstone = writeTombstone;
|
|
5
|
+
exports.readTombstone = readTombstone;
|
|
6
|
+
exports.clearTombstone = clearTombstone;
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const os_1 = require("os");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
function tombstonePath() {
|
|
11
|
+
return (0, path_1.join)((0, os_1.homedir)(), '.workrail', 'primary.tombstone');
|
|
12
|
+
}
|
|
13
|
+
function writeTombstone(port, pid, deps = {}) {
|
|
14
|
+
try {
|
|
15
|
+
const mkdirFn = deps.mkdirSync ?? fs_1.mkdirSync;
|
|
16
|
+
mkdirFn((0, path_1.join)((0, os_1.homedir)(), '.workrail'), { recursive: true });
|
|
17
|
+
const writeFn = deps.writeSync ?? fs_1.writeFileSync;
|
|
18
|
+
const tombstone = {
|
|
19
|
+
pid,
|
|
20
|
+
port,
|
|
21
|
+
diedAt: new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
writeFn(tombstonePath(), JSON.stringify(tombstone, null, 2));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function readTombstone(deps = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const readFn = deps.readSync ?? fs_1.readFileSync;
|
|
31
|
+
const content = readFn(tombstonePath(), 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(content);
|
|
33
|
+
if (typeof parsed.pid === 'number' &&
|
|
34
|
+
typeof parsed.port === 'number' &&
|
|
35
|
+
typeof parsed.diedAt === 'string') {
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function clearTombstone(deps = {}) {
|
|
45
|
+
try {
|
|
46
|
+
const unlinkFn = deps.unlinkSync ?? fs_1.unlinkSync;
|
|
47
|
+
unlinkFn(tombstonePath());
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -23,12 +23,18 @@ function wireShutdownHooks(opts) {
|
|
|
23
23
|
shutdownStarted = true;
|
|
24
24
|
void (async () => {
|
|
25
25
|
try {
|
|
26
|
-
|
|
26
|
+
try {
|
|
27
|
+
process.stderr.write(`[Shutdown] Requested by ${event.signal}. Stopping services...\n`);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
27
30
|
await opts.onBeforeTerminate();
|
|
28
31
|
terminator.terminate({ kind: 'success' });
|
|
29
32
|
}
|
|
30
33
|
catch (err) {
|
|
31
|
-
|
|
34
|
+
try {
|
|
35
|
+
process.stderr.write(`[Shutdown] Error while stopping services: ${err?.stack ?? String(err)}\n`);
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
32
38
|
terminator.terminate({ kind: 'failure' });
|
|
33
39
|
}
|
|
34
40
|
})();
|
|
@@ -40,10 +46,16 @@ function wireStdoutShutdown(opts) {
|
|
|
40
46
|
stdout.on('error', (err) => {
|
|
41
47
|
const code = err.code;
|
|
42
48
|
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') {
|
|
43
|
-
|
|
49
|
+
try {
|
|
50
|
+
process.stderr.write('[MCP] stdout pipe broken (client disconnected), initiating shutdown\n');
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
44
53
|
}
|
|
45
54
|
else {
|
|
46
|
-
|
|
55
|
+
try {
|
|
56
|
+
process.stderr.write(`[MCP] stdout error: ${err.message}\n`);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
47
59
|
}
|
|
48
60
|
shutdownEvents.emit({ kind: 'shutdown_requested', signal: 'SIGHUP' });
|
|
49
61
|
});
|
|
@@ -52,7 +64,10 @@ function wireStdinShutdown(opts) {
|
|
|
52
64
|
const shutdownEvents = container_js_1.container.resolve(tokens_js_1.DI.Runtime.ShutdownEvents);
|
|
53
65
|
const stdin = opts?.stdin ?? process.stdin;
|
|
54
66
|
stdin.once('end', () => {
|
|
55
|
-
|
|
67
|
+
try {
|
|
68
|
+
process.stderr.write('[MCP] stdin closed, initiating shutdown\n');
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
56
71
|
shutdownEvents.emit({ kind: 'shutdown_requested', signal: 'SIGHUP' });
|
|
57
72
|
});
|
|
58
73
|
}
|
|
@@ -37,6 +37,7 @@ exports.startStdioServer = startStdioServer;
|
|
|
37
37
|
const server_js_1 = require("../server.js");
|
|
38
38
|
const shutdown_hooks_js_1 = require("./shutdown-hooks.js");
|
|
39
39
|
const fatal_exit_js_1 = require("./fatal-exit.js");
|
|
40
|
+
const primary_tombstone_js_1 = require("./primary-tombstone.js");
|
|
40
41
|
const INITIAL_ROOTS_TIMEOUT_MS = 1000;
|
|
41
42
|
async function fetchInitialRootsWithTimeout(server) {
|
|
42
43
|
return Promise.race([
|
|
@@ -49,7 +50,9 @@ async function fetchInitialRootsWithTimeout(server) {
|
|
|
49
50
|
async function startStdioServer() {
|
|
50
51
|
(0, fatal_exit_js_1.registerFatalHandlers)('stdio');
|
|
51
52
|
(0, fatal_exit_js_1.logStartup)('stdio');
|
|
53
|
+
(0, primary_tombstone_js_1.clearTombstone)();
|
|
52
54
|
const { server, ctx, rootsManager } = await (0, server_js_1.composeServer)();
|
|
55
|
+
(0, fatal_exit_js_1.registerGracefulShutdown)(async () => { await ctx.httpServer?.stop(); });
|
|
53
56
|
const { StdioServerTransport } = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/stdio.js')));
|
|
54
57
|
const { RootsListChangedNotificationSchema, } = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/types.js')));
|
|
55
58
|
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
|
@@ -81,6 +84,10 @@ async function startStdioServer() {
|
|
|
81
84
|
(0, shutdown_hooks_js_1.wireStdinShutdown)();
|
|
82
85
|
(0, shutdown_hooks_js_1.wireShutdownHooks)({
|
|
83
86
|
onBeforeTerminate: async () => {
|
|
87
|
+
const port = ctx.httpServer?.getPort();
|
|
88
|
+
if (port != null) {
|
|
89
|
+
(0, primary_tombstone_js_1.writeTombstone)(port, process.pid);
|
|
90
|
+
}
|
|
84
91
|
await ctx.httpServer?.stop();
|
|
85
92
|
},
|
|
86
93
|
});
|
package/dist/mcp-server.js
CHANGED
|
@@ -37,16 +37,16 @@ function waitForStdinReadable(timeoutMs, stdin = process.stdin) {
|
|
|
37
37
|
async function main() {
|
|
38
38
|
const mode = (0, transport_mode_js_1.resolveTransportMode)(process.env);
|
|
39
39
|
if (mode.kind === 'stdio') {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
40
|
+
const primaryDetected = await (0, bridge_entry_js_1.detectHealthyPrimary)(DEFAULT_MCP_PORT);
|
|
41
|
+
if (primaryDetected != null) {
|
|
42
42
|
const hasClient = await waitForStdinReadable(STDIO_CLIENT_PROBE_MS, process.stdin);
|
|
43
43
|
if (!hasClient) {
|
|
44
|
-
console.error(`[Startup] Primary on :${
|
|
44
|
+
console.error(`[Startup] Primary on :${primaryDetected.port}, no stdio client within ${STDIO_CLIENT_PROBE_MS}ms — exiting`);
|
|
45
45
|
process.exit(0);
|
|
46
46
|
}
|
|
47
|
-
console.error(`[Startup] Primary detected on :${
|
|
47
|
+
console.error(`[Startup] Primary detected on :${primaryDetected.port} — starting in bridge mode`);
|
|
48
48
|
try {
|
|
49
|
-
await (0, bridge_entry_js_1.startBridgeServer)(
|
|
49
|
+
await (0, bridge_entry_js_1.startBridgeServer)(primaryDetected.port, undefined, { originalPrimaryPid: primaryDetected.pid });
|
|
50
50
|
}
|
|
51
51
|
catch (error) {
|
|
52
52
|
console.error('[Bridge] Fatal error, falling back to full stdio server:', error);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import type { V2ToolContext } from '../mcp/types.js';
|
|
3
|
+
import type { TriggerRouter } from './trigger-router.js';
|
|
4
|
+
import type { WorkflowService } from '../application/services/workflow-service.js';
|
|
5
|
+
import type { Result } from '../runtime/result.js';
|
|
6
|
+
export interface DaemonConsoleHandle {
|
|
7
|
+
readonly port: number;
|
|
8
|
+
stop(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export type DaemonConsoleError = {
|
|
11
|
+
readonly kind: 'port_conflict';
|
|
12
|
+
readonly port: number;
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: 'io_error';
|
|
15
|
+
readonly message: string;
|
|
16
|
+
};
|
|
17
|
+
export interface StartDaemonConsoleOptions {
|
|
18
|
+
readonly port?: number;
|
|
19
|
+
readonly triggerRouter?: TriggerRouter;
|
|
20
|
+
readonly serverVersion?: string;
|
|
21
|
+
readonly workflowService?: WorkflowService;
|
|
22
|
+
readonly lockFilePath?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function startDaemonConsole(ctx: V2ToolContext, options?: StartDaemonConsoleOptions): Promise<Result<DaemonConsoleHandle, DaemonConsoleError>>;
|