mock-mcp 0.3.0 → 0.5.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/README.md +217 -128
- package/dist/adapter/index.cjs +712 -0
- package/dist/adapter/index.d.cts +55 -0
- package/dist/adapter/index.d.ts +55 -0
- package/dist/adapter/index.js +672 -0
- package/dist/client/connect.cjs +913 -0
- package/dist/client/connect.d.cts +211 -0
- package/dist/client/connect.d.ts +209 -6
- package/dist/client/connect.js +867 -10
- package/dist/client/index.cjs +914 -0
- package/dist/client/index.d.cts +4 -0
- package/dist/client/index.d.ts +4 -2
- package/dist/client/index.js +873 -2
- package/dist/daemon/index.cjs +667 -0
- package/dist/daemon/index.d.cts +62 -0
- package/dist/daemon/index.d.ts +62 -0
- package/dist/daemon/index.js +628 -0
- package/dist/discovery-Dc2LdF8q.d.cts +105 -0
- package/dist/discovery-Dc2LdF8q.d.ts +105 -0
- package/dist/index.cjs +2238 -0
- package/dist/index.d.cts +472 -0
- package/dist/index.d.ts +472 -10
- package/dist/index.js +2185 -53
- package/dist/protocol-CiwaQFOt.d.ts +239 -0
- package/dist/protocol-xZu-wb0n.d.cts +239 -0
- package/dist/shared/index.cjs +386 -0
- package/dist/shared/index.d.cts +4 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.js +310 -0
- package/dist/types-BKREdsyr.d.cts +32 -0
- package/dist/types-BKREdsyr.d.ts +32 -0
- package/package.json +44 -4
- package/dist/client/batch-mock-collector.d.ts +0 -87
- package/dist/client/batch-mock-collector.js +0 -223
- package/dist/client/util.d.ts +0 -1
- package/dist/client/util.js +0 -3
- package/dist/connect.cjs +0 -299
- package/dist/connect.d.cts +0 -95
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.js +0 -1
- package/dist/server/test-mock-mcp-server.d.ts +0 -73
- package/dist/server/test-mock-mcp-server.js +0 -392
- package/dist/types.d.ts +0 -42
- package/dist/types.js +0 -2
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var http2 = require('http');
|
|
4
|
+
var fs2 = require('fs/promises');
|
|
5
|
+
var crypto = require('crypto');
|
|
6
|
+
var ws = require('ws');
|
|
7
|
+
var fssync = require('fs');
|
|
8
|
+
var os = require('os');
|
|
9
|
+
var path = require('path');
|
|
10
|
+
require('child_process');
|
|
11
|
+
var url = require('url');
|
|
12
|
+
require('module');
|
|
13
|
+
|
|
14
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
15
|
+
|
|
16
|
+
var http2__default = /*#__PURE__*/_interopDefault(http2);
|
|
17
|
+
var fs2__default = /*#__PURE__*/_interopDefault(fs2);
|
|
18
|
+
var crypto__default = /*#__PURE__*/_interopDefault(crypto);
|
|
19
|
+
var fssync__default = /*#__PURE__*/_interopDefault(fssync);
|
|
20
|
+
var os__default = /*#__PURE__*/_interopDefault(os);
|
|
21
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
22
|
+
|
|
23
|
+
var __importMetaUrl = (function() {
|
|
24
|
+
if (typeof document !== 'undefined') {
|
|
25
|
+
return document.currentScript && document.currentScript.src || new URL('main.js', document.baseURI).href;
|
|
26
|
+
}
|
|
27
|
+
// Node.js CJS context
|
|
28
|
+
// When this bundle is re-bundled by another tool (e.g., esbuild, webpack),
|
|
29
|
+
// __filename may not be defined or may not be a valid file path.
|
|
30
|
+
// We need to handle these cases gracefully.
|
|
31
|
+
try {
|
|
32
|
+
if (typeof __filename !== 'undefined' && __filename) {
|
|
33
|
+
var url = require('url');
|
|
34
|
+
var path = require('path');
|
|
35
|
+
// Check if __filename looks like a valid file path
|
|
36
|
+
if (path.isAbsolute(__filename) || __filename.startsWith('./') || __filename.startsWith('../')) {
|
|
37
|
+
return url.pathToFileURL(__filename).href;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Fallback if pathToFileURL fails
|
|
42
|
+
}
|
|
43
|
+
// Fallback: use process.cwd() as the base URL
|
|
44
|
+
// This is not perfect but allows the code to continue working
|
|
45
|
+
try {
|
|
46
|
+
var url = require('url');
|
|
47
|
+
return url.pathToFileURL(require('path').join(process.cwd(), 'index.cjs')).href;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return 'file:///unknown';
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
(() => {
|
|
53
|
+
try {
|
|
54
|
+
const metaUrl = __importMetaUrl;
|
|
55
|
+
if (metaUrl && typeof metaUrl === "string" && metaUrl.startsWith("file://")) {
|
|
56
|
+
return path__default.default.dirname(url.fileURLToPath(metaUrl));
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
return process.cwd();
|
|
61
|
+
})();
|
|
62
|
+
function computeProjectId(projectRoot) {
|
|
63
|
+
const real = fssync__default.default.realpathSync(projectRoot);
|
|
64
|
+
return crypto__default.default.createHash("sha256").update(real).digest("hex").slice(0, 16);
|
|
65
|
+
}
|
|
66
|
+
function getCacheDir(override) {
|
|
67
|
+
if (override) {
|
|
68
|
+
return override;
|
|
69
|
+
}
|
|
70
|
+
const envCacheDir = process.env.MOCK_MCP_CACHE_DIR;
|
|
71
|
+
if (envCacheDir) {
|
|
72
|
+
return envCacheDir;
|
|
73
|
+
}
|
|
74
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
75
|
+
if (xdg) {
|
|
76
|
+
return xdg;
|
|
77
|
+
}
|
|
78
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
79
|
+
return process.env.LOCALAPPDATA;
|
|
80
|
+
}
|
|
81
|
+
const home = os__default.default.homedir();
|
|
82
|
+
if (home) {
|
|
83
|
+
return path__default.default.join(home, ".cache");
|
|
84
|
+
}
|
|
85
|
+
return os__default.default.tmpdir();
|
|
86
|
+
}
|
|
87
|
+
function getPaths(projectId, cacheDir) {
|
|
88
|
+
const base = path__default.default.join(getCacheDir(cacheDir), "mock-mcp");
|
|
89
|
+
const registryPath = path__default.default.join(base, `${projectId}.json`);
|
|
90
|
+
const lockPath = path__default.default.join(base, `${projectId}.lock`);
|
|
91
|
+
const ipcPath = process.platform === "win32" ? `\\\\.\\pipe\\mock-mcp-${projectId}` : path__default.default.join(base, `${projectId}.sock`);
|
|
92
|
+
return { base, registryPath, lockPath, ipcPath };
|
|
93
|
+
}
|
|
94
|
+
async function writeRegistry(registryPath, registry) {
|
|
95
|
+
await fs2__default.default.writeFile(registryPath, JSON.stringify(registry, null, 2), {
|
|
96
|
+
encoding: "utf-8",
|
|
97
|
+
mode: 384
|
|
98
|
+
// Read/write for owner only
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/shared/protocol.ts
|
|
103
|
+
var HELLO_TEST = "HELLO_TEST";
|
|
104
|
+
var HELLO_ACK = "HELLO_ACK";
|
|
105
|
+
var BATCH_MOCK_REQUEST = "BATCH_MOCK_REQUEST";
|
|
106
|
+
var BATCH_MOCK_RESULT = "BATCH_MOCK_RESULT";
|
|
107
|
+
var HEARTBEAT = "HEARTBEAT";
|
|
108
|
+
var HEARTBEAT_ACK = "HEARTBEAT_ACK";
|
|
109
|
+
var RPC_GET_STATUS = "getStatus";
|
|
110
|
+
var RPC_LIST_RUNS = "listRuns";
|
|
111
|
+
var RPC_CLAIM_NEXT_BATCH = "claimNextBatch";
|
|
112
|
+
var RPC_PROVIDE_BATCH = "provideBatch";
|
|
113
|
+
var RPC_RELEASE_BATCH = "releaseBatch";
|
|
114
|
+
var RPC_GET_BATCH = "getBatch";
|
|
115
|
+
var RPC_ERROR_METHOD_NOT_FOUND = -32601;
|
|
116
|
+
var RPC_ERROR_INTERNAL = -32603;
|
|
117
|
+
var RPC_ERROR_NOT_FOUND = -32e3;
|
|
118
|
+
var RPC_ERROR_UNAUTHORIZED = -32001;
|
|
119
|
+
var RPC_ERROR_CONFLICT = -32002;
|
|
120
|
+
var RPC_ERROR_EXPIRED = -32003;
|
|
121
|
+
function isHelloTestMessage(msg) {
|
|
122
|
+
if (!msg || typeof msg !== "object") return false;
|
|
123
|
+
const m = msg;
|
|
124
|
+
return m.type === HELLO_TEST && typeof m.token === "string" && typeof m.runId === "string" && typeof m.pid === "number" && typeof m.cwd === "string";
|
|
125
|
+
}
|
|
126
|
+
function isBatchMockRequestMessage(msg) {
|
|
127
|
+
if (!msg || typeof msg !== "object") return false;
|
|
128
|
+
const m = msg;
|
|
129
|
+
return m.type === BATCH_MOCK_REQUEST && typeof m.runId === "string" && Array.isArray(m.requests);
|
|
130
|
+
}
|
|
131
|
+
function isHeartbeatMessage(msg) {
|
|
132
|
+
if (!msg || typeof msg !== "object") return false;
|
|
133
|
+
const m = msg;
|
|
134
|
+
return m.type === HEARTBEAT && typeof m.runId === "string";
|
|
135
|
+
}
|
|
136
|
+
function isJsonRpcRequest(msg) {
|
|
137
|
+
if (!msg || typeof msg !== "object") return false;
|
|
138
|
+
const m = msg;
|
|
139
|
+
return m.jsonrpc === "2.0" && (typeof m.id === "string" || typeof m.id === "number") && typeof m.method === "string";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/daemon/daemon.ts
|
|
143
|
+
var MockMcpDaemon = class {
|
|
144
|
+
logger;
|
|
145
|
+
opts;
|
|
146
|
+
server;
|
|
147
|
+
wss;
|
|
148
|
+
sweepTimer;
|
|
149
|
+
idleTimer;
|
|
150
|
+
startedAt;
|
|
151
|
+
// State management
|
|
152
|
+
runs = /* @__PURE__ */ new Map();
|
|
153
|
+
batches = /* @__PURE__ */ new Map();
|
|
154
|
+
pendingQueue = [];
|
|
155
|
+
// batchIds in order
|
|
156
|
+
batchSeq = 0;
|
|
157
|
+
constructor(options) {
|
|
158
|
+
this.logger = options.logger ?? console;
|
|
159
|
+
this.opts = {
|
|
160
|
+
projectRoot: options.projectRoot,
|
|
161
|
+
token: options.token,
|
|
162
|
+
version: options.version,
|
|
163
|
+
cacheDir: options.cacheDir,
|
|
164
|
+
logger: this.logger,
|
|
165
|
+
defaultLeaseMs: options.defaultLeaseMs ?? 3e4,
|
|
166
|
+
sweepIntervalMs: options.sweepIntervalMs ?? 5e3,
|
|
167
|
+
idleShutdownMs: options.idleShutdownMs ?? 6e5
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// ===========================================================================
|
|
171
|
+
// Lifecycle
|
|
172
|
+
// ===========================================================================
|
|
173
|
+
async start() {
|
|
174
|
+
const projectId = computeProjectId(this.opts.projectRoot);
|
|
175
|
+
const { base, registryPath, ipcPath } = getPaths(projectId, this.opts.cacheDir);
|
|
176
|
+
await fs2__default.default.mkdir(base, { recursive: true });
|
|
177
|
+
if (process.platform !== "win32") {
|
|
178
|
+
try {
|
|
179
|
+
await fs2__default.default.rm(ipcPath);
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const server = http2__default.default.createServer((req, res) => this.handleHttp(req, res));
|
|
184
|
+
this.server = server;
|
|
185
|
+
const wss = new ws.WebSocketServer({ noServer: true });
|
|
186
|
+
this.wss = wss;
|
|
187
|
+
server.on("upgrade", (req, socket, head) => {
|
|
188
|
+
if (!req.url?.startsWith("/test")) {
|
|
189
|
+
socket.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
193
|
+
wss.emit("connection", ws, req);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
wss.on("connection", (ws, req) => this.handleWsConnection(ws, req));
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
server.once("error", reject);
|
|
199
|
+
server.listen(ipcPath, () => {
|
|
200
|
+
this.logger.error(`\u{1F680} Daemon listening on ${ipcPath}`);
|
|
201
|
+
resolve();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
this.startedAt = Date.now();
|
|
205
|
+
const registry = {
|
|
206
|
+
projectId,
|
|
207
|
+
projectRoot: this.opts.projectRoot,
|
|
208
|
+
ipcPath,
|
|
209
|
+
token: this.opts.token,
|
|
210
|
+
pid: process.pid,
|
|
211
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
212
|
+
version: this.opts.version
|
|
213
|
+
};
|
|
214
|
+
await writeRegistry(registryPath, registry);
|
|
215
|
+
this.sweepTimer = setInterval(() => this.sweepExpiredClaims(), this.opts.sweepIntervalMs);
|
|
216
|
+
this.sweepTimer.unref?.();
|
|
217
|
+
this.resetIdleTimer();
|
|
218
|
+
this.logger.error(`\u2705 Daemon ready (project: ${projectId}, pid: ${process.pid})`);
|
|
219
|
+
}
|
|
220
|
+
async stop() {
|
|
221
|
+
if (this.sweepTimer) {
|
|
222
|
+
clearInterval(this.sweepTimer);
|
|
223
|
+
this.sweepTimer = void 0;
|
|
224
|
+
}
|
|
225
|
+
if (this.idleTimer) {
|
|
226
|
+
clearTimeout(this.idleTimer);
|
|
227
|
+
this.idleTimer = void 0;
|
|
228
|
+
}
|
|
229
|
+
for (const run of this.runs.values()) {
|
|
230
|
+
run.ws.close(1001, "Daemon shutting down");
|
|
231
|
+
}
|
|
232
|
+
this.runs.clear();
|
|
233
|
+
this.wss?.close();
|
|
234
|
+
await new Promise((resolve) => {
|
|
235
|
+
if (!this.server) {
|
|
236
|
+
resolve();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.server.close(() => resolve());
|
|
240
|
+
});
|
|
241
|
+
this.batches.clear();
|
|
242
|
+
this.pendingQueue.length = 0;
|
|
243
|
+
this.logger.error("\u{1F44B} Daemon stopped");
|
|
244
|
+
}
|
|
245
|
+
// ===========================================================================
|
|
246
|
+
// HTTP Handler (/health, /control)
|
|
247
|
+
// ===========================================================================
|
|
248
|
+
async handleHttp(req, res) {
|
|
249
|
+
try {
|
|
250
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
251
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
252
|
+
res.end(
|
|
253
|
+
JSON.stringify({
|
|
254
|
+
ok: true,
|
|
255
|
+
pid: process.pid,
|
|
256
|
+
version: this.opts.version,
|
|
257
|
+
projectId: computeProjectId(this.opts.projectRoot)
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (req.method === "POST" && req.url === "/control") {
|
|
263
|
+
const token = req.headers["x-mock-mcp-token"];
|
|
264
|
+
if (token !== this.opts.token) {
|
|
265
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
266
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const body = await this.readBody(req);
|
|
270
|
+
let rpcReq;
|
|
271
|
+
try {
|
|
272
|
+
rpcReq = JSON.parse(body);
|
|
273
|
+
} catch {
|
|
274
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
275
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!isJsonRpcRequest(rpcReq)) {
|
|
279
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
280
|
+
res.end(JSON.stringify({ error: "Invalid JSON-RPC request" }));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const rpcRes = await this.handleRpc(rpcReq);
|
|
284
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
285
|
+
res.end(JSON.stringify(rpcRes));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
289
|
+
res.end("Not Found");
|
|
290
|
+
} catch (e) {
|
|
291
|
+
this.logger.error("HTTP handler error:", e);
|
|
292
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
293
|
+
res.end(e instanceof Error ? e.message : String(e));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
readBody(req) {
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
let buf = "";
|
|
299
|
+
req.on("data", (chunk) => buf += chunk);
|
|
300
|
+
req.on("end", () => resolve(buf));
|
|
301
|
+
req.on("error", reject);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// ===========================================================================
|
|
305
|
+
// WebSocket Handler (/test)
|
|
306
|
+
// ===========================================================================
|
|
307
|
+
handleWsConnection(ws, _req) {
|
|
308
|
+
let authed = false;
|
|
309
|
+
let runId = null;
|
|
310
|
+
const helloTimeout = setTimeout(() => {
|
|
311
|
+
if (!authed) {
|
|
312
|
+
ws.close(1008, "HELLO timeout");
|
|
313
|
+
}
|
|
314
|
+
}, 5e3);
|
|
315
|
+
ws.on("message", (data) => {
|
|
316
|
+
let msg;
|
|
317
|
+
try {
|
|
318
|
+
msg = JSON.parse(data.toString());
|
|
319
|
+
} catch {
|
|
320
|
+
this.logger.warn("Invalid JSON from test process");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!authed) {
|
|
324
|
+
if (!isHelloTestMessage(msg)) {
|
|
325
|
+
ws.close(1008, "Expected HELLO_TEST");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (msg.token !== this.opts.token) {
|
|
329
|
+
ws.close(1008, "Invalid token");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
authed = true;
|
|
333
|
+
clearTimeout(helloTimeout);
|
|
334
|
+
runId = msg.runId;
|
|
335
|
+
const runState = {
|
|
336
|
+
runId,
|
|
337
|
+
pid: msg.pid,
|
|
338
|
+
cwd: msg.cwd,
|
|
339
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
340
|
+
lastSeen: Date.now(),
|
|
341
|
+
testMeta: msg.testMeta,
|
|
342
|
+
ws
|
|
343
|
+
};
|
|
344
|
+
this.runs.set(runId, runState);
|
|
345
|
+
const ack = { type: HELLO_ACK, runId };
|
|
346
|
+
ws.send(JSON.stringify(ack));
|
|
347
|
+
this.logger.error(`\u{1F50C} Test run connected: ${runId} (pid: ${msg.pid})`);
|
|
348
|
+
this.resetIdleTimer();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (isBatchMockRequestMessage(msg)) {
|
|
352
|
+
this.handleBatchRequest(runId, msg.requests, ws);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (isHeartbeatMessage(msg)) {
|
|
356
|
+
const run = this.runs.get(msg.runId);
|
|
357
|
+
if (run) {
|
|
358
|
+
run.lastSeen = Date.now();
|
|
359
|
+
}
|
|
360
|
+
const ack = { type: HEARTBEAT_ACK, runId: msg.runId };
|
|
361
|
+
ws.send(JSON.stringify(ack));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
ws.on("close", () => {
|
|
366
|
+
clearTimeout(helloTimeout);
|
|
367
|
+
if (runId) {
|
|
368
|
+
this.cleanupRun(runId);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
ws.on("error", (err) => {
|
|
372
|
+
this.logger.error("WebSocket error:", err);
|
|
373
|
+
if (runId) {
|
|
374
|
+
this.cleanupRun(runId);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
handleBatchRequest(runId, requests, ws) {
|
|
379
|
+
const batchId = `batch:${runId}:${++this.batchSeq}`;
|
|
380
|
+
const batch = {
|
|
381
|
+
batchId,
|
|
382
|
+
runId,
|
|
383
|
+
requests,
|
|
384
|
+
createdAt: Date.now(),
|
|
385
|
+
status: "pending"
|
|
386
|
+
};
|
|
387
|
+
this.batches.set(batchId, batch);
|
|
388
|
+
this.pendingQueue.push(batchId);
|
|
389
|
+
this.logger.error(
|
|
390
|
+
[
|
|
391
|
+
`\u{1F4E5} Received ${requests.length} request(s) (${batchId})`,
|
|
392
|
+
...requests.map(
|
|
393
|
+
(req, i) => ` ${i + 1}. ${req.method} ${req.endpoint} (${req.requestId})`
|
|
394
|
+
)
|
|
395
|
+
].join("\n")
|
|
396
|
+
);
|
|
397
|
+
this.logger.error("\u23F3 Awaiting mock data from MCP adapter...");
|
|
398
|
+
}
|
|
399
|
+
cleanupRun(runId) {
|
|
400
|
+
const run = this.runs.get(runId);
|
|
401
|
+
if (!run) return;
|
|
402
|
+
this.runs.delete(runId);
|
|
403
|
+
for (const [batchId, batch] of this.batches) {
|
|
404
|
+
if (batch.runId === runId) {
|
|
405
|
+
this.batches.delete(batchId);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (let i = this.pendingQueue.length - 1; i >= 0; i--) {
|
|
409
|
+
const bid = this.pendingQueue[i];
|
|
410
|
+
if (!this.batches.has(bid)) {
|
|
411
|
+
this.pendingQueue.splice(i, 1);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.logger.error(`\u{1F50C} Test run disconnected: ${runId}`);
|
|
415
|
+
this.resetIdleTimer();
|
|
416
|
+
}
|
|
417
|
+
// ===========================================================================
|
|
418
|
+
// JSON-RPC Handler
|
|
419
|
+
// ===========================================================================
|
|
420
|
+
async handleRpc(req) {
|
|
421
|
+
try {
|
|
422
|
+
this.sweepExpiredClaims();
|
|
423
|
+
const params = req.params ?? {};
|
|
424
|
+
switch (req.method) {
|
|
425
|
+
case RPC_GET_STATUS:
|
|
426
|
+
return this.rpcSuccess(req.id, this.getStatus());
|
|
427
|
+
case RPC_LIST_RUNS:
|
|
428
|
+
return this.rpcSuccess(req.id, this.listRuns());
|
|
429
|
+
case RPC_CLAIM_NEXT_BATCH:
|
|
430
|
+
return this.rpcSuccess(req.id, this.claimNextBatch(params));
|
|
431
|
+
case RPC_PROVIDE_BATCH:
|
|
432
|
+
return this.rpcSuccess(req.id, await this.provideBatch(params));
|
|
433
|
+
case RPC_RELEASE_BATCH:
|
|
434
|
+
return this.rpcSuccess(req.id, this.releaseBatch(params));
|
|
435
|
+
case RPC_GET_BATCH:
|
|
436
|
+
return this.rpcSuccess(req.id, this.getBatch(params));
|
|
437
|
+
default:
|
|
438
|
+
return this.rpcError(req.id, RPC_ERROR_METHOD_NOT_FOUND, `Unknown method: ${req.method}`);
|
|
439
|
+
}
|
|
440
|
+
} catch (e) {
|
|
441
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
442
|
+
const code = this.getErrorCode(e);
|
|
443
|
+
return this.rpcError(req.id, code, msg);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
getErrorCode(e) {
|
|
447
|
+
if (e instanceof RpcError) {
|
|
448
|
+
return e.code;
|
|
449
|
+
}
|
|
450
|
+
return RPC_ERROR_INTERNAL;
|
|
451
|
+
}
|
|
452
|
+
rpcSuccess(id, result) {
|
|
453
|
+
return { jsonrpc: "2.0", id, result };
|
|
454
|
+
}
|
|
455
|
+
rpcError(id, code, message) {
|
|
456
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
457
|
+
}
|
|
458
|
+
// ===========================================================================
|
|
459
|
+
// RPC Methods
|
|
460
|
+
// ===========================================================================
|
|
461
|
+
getStatus() {
|
|
462
|
+
const pending = this.pendingQueue.filter((bid) => {
|
|
463
|
+
const b = this.batches.get(bid);
|
|
464
|
+
return b && b.status === "pending";
|
|
465
|
+
}).length;
|
|
466
|
+
const claimed = Array.from(this.batches.values()).filter(
|
|
467
|
+
(b) => b.status === "claimed"
|
|
468
|
+
).length;
|
|
469
|
+
return {
|
|
470
|
+
version: this.opts.version,
|
|
471
|
+
projectId: computeProjectId(this.opts.projectRoot),
|
|
472
|
+
projectRoot: this.opts.projectRoot,
|
|
473
|
+
pid: process.pid,
|
|
474
|
+
uptime: this.startedAt ? Date.now() - this.startedAt : 0,
|
|
475
|
+
runs: this.runs.size,
|
|
476
|
+
pending,
|
|
477
|
+
claimed,
|
|
478
|
+
totalBatches: this.batches.size
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
listRuns() {
|
|
482
|
+
const runs = Array.from(this.runs.values()).map((r) => {
|
|
483
|
+
const pendingBatches = Array.from(this.batches.values()).filter(
|
|
484
|
+
(b) => b.runId === r.runId && b.status === "pending"
|
|
485
|
+
).length;
|
|
486
|
+
return {
|
|
487
|
+
runId: r.runId,
|
|
488
|
+
pid: r.pid,
|
|
489
|
+
cwd: r.cwd,
|
|
490
|
+
startedAt: r.startedAt,
|
|
491
|
+
lastSeen: r.lastSeen,
|
|
492
|
+
pendingBatches,
|
|
493
|
+
testMeta: r.testMeta
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
return { runs };
|
|
497
|
+
}
|
|
498
|
+
claimNextBatch(params) {
|
|
499
|
+
const { adapterId, runId, leaseMs = this.opts.defaultLeaseMs } = params;
|
|
500
|
+
if (!adapterId) {
|
|
501
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "adapterId required");
|
|
502
|
+
}
|
|
503
|
+
for (let i = 0; i < this.pendingQueue.length; i++) {
|
|
504
|
+
const batchId = this.pendingQueue[i];
|
|
505
|
+
const batch = this.batches.get(batchId);
|
|
506
|
+
if (!batch || batch.status !== "pending") {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (runId && batch.runId !== runId) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
this.pendingQueue.splice(i, 1);
|
|
513
|
+
batch.status = "claimed";
|
|
514
|
+
batch.claim = {
|
|
515
|
+
adapterId,
|
|
516
|
+
claimToken: crypto__default.default.randomUUID(),
|
|
517
|
+
leaseUntil: Date.now() + leaseMs
|
|
518
|
+
};
|
|
519
|
+
this.logger.error(`\u{1F512} Batch ${batchId} claimed by adapter ${adapterId.slice(0, 8)}...`);
|
|
520
|
+
return {
|
|
521
|
+
batchId: batch.batchId,
|
|
522
|
+
runId: batch.runId,
|
|
523
|
+
requests: batch.requests,
|
|
524
|
+
claimToken: batch.claim.claimToken,
|
|
525
|
+
leaseUntil: batch.claim.leaseUntil
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
async provideBatch(params) {
|
|
531
|
+
const { adapterId, batchId, claimToken, mocks } = params;
|
|
532
|
+
const batch = this.batches.get(batchId);
|
|
533
|
+
if (!batch) {
|
|
534
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, `Batch not found: ${batchId}`);
|
|
535
|
+
}
|
|
536
|
+
if (batch.status !== "claimed" || !batch.claim) {
|
|
537
|
+
throw new RpcError(RPC_ERROR_CONFLICT, `Batch not in claimed state: ${batchId}`);
|
|
538
|
+
}
|
|
539
|
+
if (batch.claim.adapterId !== adapterId) {
|
|
540
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "Not the owner of this batch");
|
|
541
|
+
}
|
|
542
|
+
if (batch.claim.claimToken !== claimToken) {
|
|
543
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "Invalid claim token");
|
|
544
|
+
}
|
|
545
|
+
if (batch.claim.leaseUntil <= Date.now()) {
|
|
546
|
+
batch.status = "pending";
|
|
547
|
+
batch.claim = void 0;
|
|
548
|
+
this.pendingQueue.push(batchId);
|
|
549
|
+
throw new RpcError(RPC_ERROR_EXPIRED, "Claim lease expired");
|
|
550
|
+
}
|
|
551
|
+
this.validateMocks(batch, mocks);
|
|
552
|
+
const run = this.runs.get(batch.runId);
|
|
553
|
+
if (!run) {
|
|
554
|
+
this.batches.delete(batchId);
|
|
555
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, "Run is gone");
|
|
556
|
+
}
|
|
557
|
+
if (run.ws.readyState !== ws.WebSocket.OPEN) {
|
|
558
|
+
this.batches.delete(batchId);
|
|
559
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, "Test process disconnected");
|
|
560
|
+
}
|
|
561
|
+
const result = {
|
|
562
|
+
type: BATCH_MOCK_RESULT,
|
|
563
|
+
batchId,
|
|
564
|
+
mocks
|
|
565
|
+
};
|
|
566
|
+
run.ws.send(JSON.stringify(result));
|
|
567
|
+
batch.status = "fulfilled";
|
|
568
|
+
this.batches.delete(batchId);
|
|
569
|
+
this.logger.error(`\u2705 Delivered ${mocks.length} mock(s) for ${batchId}`);
|
|
570
|
+
return { ok: true, message: `Provided ${mocks.length} mock(s) for ${batchId}` };
|
|
571
|
+
}
|
|
572
|
+
validateMocks(batch, mocks) {
|
|
573
|
+
const expectedIds = new Set(batch.requests.map((r) => r.requestId));
|
|
574
|
+
const providedIds = /* @__PURE__ */ new Set();
|
|
575
|
+
for (const mock of mocks) {
|
|
576
|
+
if (!expectedIds.has(mock.requestId)) {
|
|
577
|
+
throw new RpcError(
|
|
578
|
+
RPC_ERROR_CONFLICT,
|
|
579
|
+
`Mock references unknown requestId: ${mock.requestId}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (providedIds.has(mock.requestId)) {
|
|
583
|
+
throw new RpcError(
|
|
584
|
+
RPC_ERROR_CONFLICT,
|
|
585
|
+
`Duplicate mock for requestId: ${mock.requestId}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
providedIds.add(mock.requestId);
|
|
589
|
+
}
|
|
590
|
+
const missing = Array.from(expectedIds).filter((id) => !providedIds.has(id));
|
|
591
|
+
if (missing.length > 0) {
|
|
592
|
+
throw new RpcError(
|
|
593
|
+
RPC_ERROR_CONFLICT,
|
|
594
|
+
`Missing mocks for requestId(s): ${missing.join(", ")}`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
releaseBatch(params) {
|
|
599
|
+
const { adapterId, batchId, claimToken } = params;
|
|
600
|
+
const batch = this.batches.get(batchId);
|
|
601
|
+
if (!batch) {
|
|
602
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, `Batch not found: ${batchId}`);
|
|
603
|
+
}
|
|
604
|
+
if (batch.status !== "claimed" || !batch.claim) {
|
|
605
|
+
throw new RpcError(RPC_ERROR_CONFLICT, `Batch not in claimed state: ${batchId}`);
|
|
606
|
+
}
|
|
607
|
+
if (batch.claim.adapterId !== adapterId || batch.claim.claimToken !== claimToken) {
|
|
608
|
+
throw new RpcError(RPC_ERROR_UNAUTHORIZED, "Not the owner of this batch");
|
|
609
|
+
}
|
|
610
|
+
batch.status = "pending";
|
|
611
|
+
batch.claim = void 0;
|
|
612
|
+
this.pendingQueue.push(batchId);
|
|
613
|
+
this.logger.error(`\u{1F513} Batch ${batchId} released by adapter`);
|
|
614
|
+
return { ok: true };
|
|
615
|
+
}
|
|
616
|
+
getBatch(params) {
|
|
617
|
+
const batch = this.batches.get(params.batchId);
|
|
618
|
+
if (!batch) {
|
|
619
|
+
throw new RpcError(RPC_ERROR_NOT_FOUND, `Batch not found: ${params.batchId}`);
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
batchId: batch.batchId,
|
|
623
|
+
runId: batch.runId,
|
|
624
|
+
requests: batch.requests,
|
|
625
|
+
status: batch.status,
|
|
626
|
+
createdAt: batch.createdAt,
|
|
627
|
+
claim: batch.claim ? { adapterId: batch.claim.adapterId, leaseUntil: batch.claim.leaseUntil } : void 0
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
// ===========================================================================
|
|
631
|
+
// Maintenance
|
|
632
|
+
// ===========================================================================
|
|
633
|
+
sweepExpiredClaims() {
|
|
634
|
+
const now = Date.now();
|
|
635
|
+
for (const batch of this.batches.values()) {
|
|
636
|
+
if (batch.status === "claimed" && batch.claim && batch.claim.leaseUntil <= now) {
|
|
637
|
+
this.logger.warn(`\u23F0 Claim expired for ${batch.batchId}, returning to pending`);
|
|
638
|
+
batch.status = "pending";
|
|
639
|
+
batch.claim = void 0;
|
|
640
|
+
this.pendingQueue.push(batch.batchId);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
resetIdleTimer() {
|
|
645
|
+
if (this.idleTimer) {
|
|
646
|
+
clearTimeout(this.idleTimer);
|
|
647
|
+
}
|
|
648
|
+
if (this.runs.size === 0) {
|
|
649
|
+
this.idleTimer = setTimeout(() => {
|
|
650
|
+
if (this.runs.size === 0) {
|
|
651
|
+
this.logger.error("\u{1F4A4} No activity, shutting down daemon...");
|
|
652
|
+
this.stop().then(() => process.exit(0));
|
|
653
|
+
}
|
|
654
|
+
}, this.opts.idleShutdownMs);
|
|
655
|
+
this.idleTimer.unref?.();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
var RpcError = class extends Error {
|
|
660
|
+
constructor(code, message) {
|
|
661
|
+
super(message);
|
|
662
|
+
this.code = code;
|
|
663
|
+
this.name = "RpcError";
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
exports.MockMcpDaemon = MockMcpDaemon;
|