@cnstra/devtools-server 3.0.0 → 3.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/dist/index.cjs +465 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +70 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +466 -100
- package/dist/index.js.map +1 -1
- package/dist/pollingTransport.d.ts +72 -0
- package/dist/pollingTransport.d.ts.map +1 -0
- package/dist/start.d.ts +32 -5
- package/dist/start.d.ts.map +1 -1
- package/package.json +7 -1
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,258 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var devtoolsDto = require('@cnstra/devtools-dto');
|
|
4
|
+
var persistDto = require('@cnstra/persist-dto');
|
|
4
5
|
var persist = require('@cnstra/persist');
|
|
6
|
+
var crypto = require('crypto');
|
|
7
|
+
var http = require('http');
|
|
8
|
+
var fs = require('fs');
|
|
9
|
+
var path = require('path');
|
|
10
|
+
var module$1 = require('module');
|
|
11
|
+
var ws = require('ws');
|
|
5
12
|
|
|
6
13
|
// src/index.ts
|
|
7
|
-
|
|
8
|
-
|
|
14
|
+
|
|
15
|
+
// src/pollingTransport.ts
|
|
16
|
+
var createSession = () => {
|
|
17
|
+
let queued = [];
|
|
18
|
+
let wake;
|
|
19
|
+
const session = {
|
|
20
|
+
client: {
|
|
21
|
+
// `readyState` 1 is what `liveSockets` checks before routing an action
|
|
22
|
+
// to a participant: a polling session is reachable as long as it has
|
|
23
|
+
// not expired, and expiry removes it outright.
|
|
24
|
+
readyState: 1,
|
|
25
|
+
send(data) {
|
|
26
|
+
queued.push(data);
|
|
27
|
+
wake?.();
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
take() {
|
|
31
|
+
const out = queued;
|
|
32
|
+
queued = [];
|
|
33
|
+
return out;
|
|
34
|
+
},
|
|
35
|
+
async wait(timeoutMs) {
|
|
36
|
+
if (queued.length) return session.take();
|
|
37
|
+
await new Promise((resolve) => {
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
wake = void 0;
|
|
40
|
+
resolve();
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
wake = () => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
wake = void 0;
|
|
45
|
+
resolve();
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
return session.take();
|
|
49
|
+
},
|
|
50
|
+
lastSeenAt: Date.now()
|
|
51
|
+
};
|
|
52
|
+
return session;
|
|
53
|
+
};
|
|
54
|
+
var CNSDevToolsPollingTransport = class {
|
|
55
|
+
constructor(server, options = {}) {
|
|
56
|
+
this.server = server;
|
|
57
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
58
|
+
this.waitMs = options.waitMs ?? 25e3;
|
|
59
|
+
this.sessionTtlMs = options.sessionTtlMs ?? 6e4;
|
|
60
|
+
}
|
|
61
|
+
/** Sessions currently held. Exposed so a leak is observable in tests. */
|
|
62
|
+
get sessionCount() {
|
|
63
|
+
return this.sessions.size;
|
|
64
|
+
}
|
|
65
|
+
/** Deliver one message from a participant. */
|
|
66
|
+
async post(sessionId, body) {
|
|
67
|
+
const session = this.session(sessionId);
|
|
68
|
+
await this.server.handleMessage(session.client, body);
|
|
69
|
+
}
|
|
70
|
+
/** Drain what is queued for a participant, parking if nothing is. */
|
|
71
|
+
async poll(sessionId) {
|
|
72
|
+
const session = this.session(sessionId);
|
|
73
|
+
return session.wait(this.waitMs);
|
|
74
|
+
}
|
|
75
|
+
/** Forget a participant and take it out of the server's routing. */
|
|
76
|
+
close(sessionId) {
|
|
77
|
+
const session = this.sessions.get(sessionId);
|
|
78
|
+
if (!session) return;
|
|
79
|
+
this.sessions.delete(sessionId);
|
|
80
|
+
this.server.removeClient(session.client);
|
|
81
|
+
}
|
|
82
|
+
session(sessionId) {
|
|
83
|
+
this.sweep();
|
|
84
|
+
const existing = this.sessions.get(sessionId);
|
|
85
|
+
if (existing) {
|
|
86
|
+
existing.lastSeenAt = Date.now();
|
|
87
|
+
return existing;
|
|
88
|
+
}
|
|
89
|
+
const created = createSession();
|
|
90
|
+
this.sessions.set(sessionId, created);
|
|
91
|
+
return created;
|
|
92
|
+
}
|
|
93
|
+
/** Drop sessions that stopped polling. Silence is the only signal they give. */
|
|
94
|
+
sweep() {
|
|
95
|
+
const cutoff = Date.now() - this.sessionTtlMs;
|
|
96
|
+
for (const [id, session] of this.sessions) {
|
|
97
|
+
if (session.lastSeenAt < cutoff) {
|
|
98
|
+
this.sessions.delete(id);
|
|
99
|
+
this.server.removeClient(session.client);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
var _require = module$1.createRequire(path.join(process.cwd(), "package.json"));
|
|
105
|
+
function findPanelUIDir() {
|
|
106
|
+
try {
|
|
107
|
+
const pkg = _require.resolve("@cnstra/devtools-panel-ui/package.json");
|
|
108
|
+
return path.join(path.dirname(pkg), "dist");
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const here = path.dirname(_require.resolve("@cnstra/devtools-server/package.json"));
|
|
113
|
+
const monorepoPath = path.join(here, "..", "..", "devtools-panel-ui", "dist");
|
|
114
|
+
if (fs.existsSync(path.join(monorepoPath, "index.html"))) return monorepoPath;
|
|
115
|
+
} catch {
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
var MIME = {
|
|
120
|
+
html: "text/html",
|
|
121
|
+
js: "application/javascript",
|
|
122
|
+
css: "text/css",
|
|
123
|
+
svg: "image/svg+xml",
|
|
124
|
+
png: "image/png",
|
|
125
|
+
woff2: "font/woff2"
|
|
126
|
+
};
|
|
127
|
+
function serveStatic(panelUIDir) {
|
|
128
|
+
return (_req, res) => {
|
|
129
|
+
const safe = (_req.url ?? "/").split("?")[0].replace(/\.\./g, "");
|
|
130
|
+
const file = safe === "/" ? "/index.html" : safe;
|
|
131
|
+
const full = path.join(panelUIDir, file);
|
|
132
|
+
if (fs.existsSync(full) && full.startsWith(panelUIDir)) {
|
|
133
|
+
const ext = full.split(".").pop() ?? "";
|
|
134
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "text/plain" });
|
|
135
|
+
res.end(fs.readFileSync(full));
|
|
136
|
+
} else {
|
|
137
|
+
const idx = path.join(panelUIDir, "index.html");
|
|
138
|
+
if (fs.existsSync(idx)) {
|
|
139
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
140
|
+
res.end(fs.readFileSync(idx));
|
|
141
|
+
} else {
|
|
142
|
+
res.writeHead(503);
|
|
143
|
+
res.end("DevTools UI not built. Run: npm run build --workspace=@cnstra/devtools-panel-ui");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function startCNSDevTools(cns, registry, options = {}) {
|
|
149
|
+
const port = options.port ?? 3141;
|
|
150
|
+
const appId = options.appId ?? "app";
|
|
151
|
+
const logEnabled = options.consoleLogEnabled ?? false;
|
|
152
|
+
const panelUIDir = findPanelUIDir();
|
|
153
|
+
if (!panelUIDir) {
|
|
154
|
+
console.warn(
|
|
155
|
+
"\n\u26A0\uFE0F CNStra DevTools UI not found.\n WebSocket server will start but the UI will not be served.\n Install @cnstra/devtools-panel-ui or build it from the monorepo.\n"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const { CNSDevToolsServerRepositoryInMemory } = await import('@cnstra/devtools-server-repository-in-memory');
|
|
159
|
+
const dtServer = new CNSDevToolsServer(
|
|
160
|
+
new CNSDevToolsServerRepositoryInMemory(),
|
|
161
|
+
options.stimulationRepository,
|
|
162
|
+
options.actions,
|
|
163
|
+
options.ui,
|
|
164
|
+
options.scheduledStimulationReader
|
|
165
|
+
);
|
|
166
|
+
const polling = new CNSDevToolsPollingTransport(dtServer, options.polling);
|
|
167
|
+
const staticHandler = panelUIDir ? serveStatic(panelUIDir) : (_, res) => {
|
|
168
|
+
res.writeHead(503);
|
|
169
|
+
res.end("DevTools UI not available.");
|
|
170
|
+
};
|
|
171
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
172
|
+
let body = "";
|
|
173
|
+
req.on("data", (chunk) => body += chunk);
|
|
174
|
+
req.on("end", () => resolve(body));
|
|
175
|
+
});
|
|
176
|
+
const httpServer = http.createServer(
|
|
177
|
+
async (req, res) => {
|
|
178
|
+
if (req.url?.startsWith("/__cnstra/poll")) {
|
|
179
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
180
|
+
res.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
|
|
181
|
+
res.setHeader("access-control-allow-headers", "content-type");
|
|
182
|
+
if (req.method === "OPTIONS") {
|
|
183
|
+
res.writeHead(204);
|
|
184
|
+
return res.end();
|
|
185
|
+
}
|
|
186
|
+
const url2 = new URL(req.url, "http://localhost");
|
|
187
|
+
const sessionId = url2.searchParams.get("sessionId");
|
|
188
|
+
if (!sessionId) {
|
|
189
|
+
res.writeHead(400);
|
|
190
|
+
return res.end("sessionId required");
|
|
191
|
+
}
|
|
192
|
+
if (req.method === "POST") {
|
|
193
|
+
const body = await readBody(req);
|
|
194
|
+
await polling.post(sessionId, body);
|
|
195
|
+
res.writeHead(204);
|
|
196
|
+
return res.end();
|
|
197
|
+
}
|
|
198
|
+
if (req.method === "DELETE") {
|
|
199
|
+
polling.close(sessionId);
|
|
200
|
+
res.writeHead(204);
|
|
201
|
+
return res.end();
|
|
202
|
+
}
|
|
203
|
+
const messages = await polling.poll(sessionId);
|
|
204
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
205
|
+
return res.end(JSON.stringify({ messages }));
|
|
206
|
+
}
|
|
207
|
+
return staticHandler(req, res);
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
const wss = new ws.WebSocketServer({ server: httpServer });
|
|
211
|
+
wss.on("connection", (ws) => {
|
|
212
|
+
ws.on("message", async (data) => {
|
|
213
|
+
try {
|
|
214
|
+
await dtServer.handleMessage(ws, data.toString());
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
ws.on("close", () => dtServer.removeClient(ws));
|
|
219
|
+
});
|
|
220
|
+
await new Promise((resolve) => httpServer.listen(port, resolve));
|
|
221
|
+
const url = `http://localhost:${port}`;
|
|
222
|
+
const { CNSDevTools } = await import('@cnstra/devtools');
|
|
223
|
+
const { CNSDevToolsTransportWs } = await import('@cnstra/devtools-transport-ws');
|
|
224
|
+
const transport = new CNSDevToolsTransportWs({ url: `ws://localhost:${port}` });
|
|
225
|
+
const devtools = new CNSDevTools(appId, transport, {
|
|
226
|
+
appName: appId,
|
|
227
|
+
consoleLogEnabled: logEnabled
|
|
228
|
+
});
|
|
229
|
+
devtools.registerCNS(cns, registry);
|
|
230
|
+
if (logEnabled || process.stdout.isTTY) {
|
|
231
|
+
console.log(`
|
|
232
|
+
\u{1F9E0} CNStra DevTools: ${url}
|
|
233
|
+
`);
|
|
234
|
+
}
|
|
235
|
+
const shouldOpen = options.open ?? process.stdout.isTTY;
|
|
236
|
+
if (shouldOpen) {
|
|
237
|
+
const { exec } = await import('child_process');
|
|
238
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
239
|
+
exec(`${cmd} ${url}`);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
url,
|
|
243
|
+
stop: () => {
|
|
244
|
+
dtServer.stop();
|
|
245
|
+
wss.close();
|
|
246
|
+
httpServer.close();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/index.ts
|
|
252
|
+
var CNSDevToolsServer = class _CNSDevToolsServer {
|
|
253
|
+
constructor(repository, stimulationRepository = new persist.CNSInMemoryStimulationRepository(), actions = [], ui = {}, scheduledStimulationReader) {
|
|
9
254
|
this.repository = repository;
|
|
255
|
+
this.scheduledStimulationReader = scheduledStimulationReader;
|
|
10
256
|
/** WebSocket → appId (for app clients) */
|
|
11
257
|
this.appSockets = /* @__PURE__ */ new Map();
|
|
12
258
|
/** appId → WebSocket[] (one app may have multiple CNS instances) */
|
|
@@ -16,6 +262,60 @@ var CNSDevToolsServer = class {
|
|
|
16
262
|
this.lastCpuUsage = process.cpuUsage();
|
|
17
263
|
this.lastCpuTime = Date.now();
|
|
18
264
|
this.stimulationRepository = stimulationRepository;
|
|
265
|
+
this.actions = [...actions, this.createSocketAction()];
|
|
266
|
+
this.ui = ui;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Run an action through the chain and flatten the three outcomes into one
|
|
270
|
+
* value: `true` acted, `false` nobody took it, a string is the reason a link
|
|
271
|
+
* failed. A link that throws must not escape into the socket's message
|
|
272
|
+
* handler, where nothing answers the panel.
|
|
273
|
+
*/
|
|
274
|
+
async carryOut(action) {
|
|
275
|
+
try {
|
|
276
|
+
return await persistDto.runCNSDevToolsAction(this.actions, action) || false;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return error instanceof Error ? error.message : String(error);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The fallback link: forward the action to the app over its own socket. This
|
|
283
|
+
* is what reaches a CNS running in-process with no queue behind it — and it
|
|
284
|
+
* is why the chain always ends here rather than rejecting.
|
|
285
|
+
*/
|
|
286
|
+
createSocketAction() {
|
|
287
|
+
return async (action, next) => {
|
|
288
|
+
if (action.type === "command") return next();
|
|
289
|
+
const appSockets = await this.resolveScopeApp(action.scopeName);
|
|
290
|
+
if (!appSockets) return next();
|
|
291
|
+
const payload = action.type === "resume" ? JSON.stringify({
|
|
292
|
+
type: "cns.stimulation.resume",
|
|
293
|
+
requestId: action.requestId,
|
|
294
|
+
scopeName: action.scopeName,
|
|
295
|
+
stimulationId: action.stimulationId,
|
|
296
|
+
stimulationAttemptId: action.stimulationAttemptId,
|
|
297
|
+
attemptNumber: action.attemptNumber,
|
|
298
|
+
entry: action.entry,
|
|
299
|
+
progress: action.progress,
|
|
300
|
+
replayOf: action.replayOf,
|
|
301
|
+
options: action.options
|
|
302
|
+
}) : JSON.stringify({
|
|
303
|
+
type: "cns.stimulation.launch",
|
|
304
|
+
requestId: action.requestId,
|
|
305
|
+
scopeName: action.scopeName,
|
|
306
|
+
stimulationId: action.stimulationId,
|
|
307
|
+
stimulationAttemptId: action.stimulationAttemptId,
|
|
308
|
+
entry: action.entry,
|
|
309
|
+
replayOf: action.replayOf,
|
|
310
|
+
options: action.options
|
|
311
|
+
});
|
|
312
|
+
for (const appWs of appSockets) {
|
|
313
|
+
try {
|
|
314
|
+
appWs.send(payload);
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
19
319
|
}
|
|
20
320
|
// ─── Message entry point ─────────────────────────────────────────────────
|
|
21
321
|
async handleMessage(ws, raw) {
|
|
@@ -49,9 +349,6 @@ var CNSDevToolsServer = class {
|
|
|
49
349
|
case "topology.query":
|
|
50
350
|
await this.handleTopologyQuery(ws, msg.requestId, msg.cnsId, msg.appId);
|
|
51
351
|
break;
|
|
52
|
-
case "replay.start":
|
|
53
|
-
await this.handleReplayStart(ws, msg);
|
|
54
|
-
break;
|
|
55
352
|
case "stimulation.retry":
|
|
56
353
|
await this.handleStimulationRetry(ws, msg);
|
|
57
354
|
break;
|
|
@@ -70,6 +367,15 @@ var CNSDevToolsServer = class {
|
|
|
70
367
|
case "cns.stimulation.detail":
|
|
71
368
|
await this.handleStimulationDetail(ws, msg);
|
|
72
369
|
break;
|
|
370
|
+
case "cns.scheduled.list":
|
|
371
|
+
await this.handleScheduledStimulationsList(ws, msg);
|
|
372
|
+
break;
|
|
373
|
+
case "stimulation.command":
|
|
374
|
+
await this.handleStimulationCommand(ws, msg);
|
|
375
|
+
break;
|
|
376
|
+
case "stimulation.launch":
|
|
377
|
+
await this.handleStimulationLaunch(ws, msg);
|
|
378
|
+
break;
|
|
73
379
|
}
|
|
74
380
|
}
|
|
75
381
|
/**
|
|
@@ -90,8 +396,8 @@ var CNSDevToolsServer = class {
|
|
|
90
396
|
const limit = typeof msg.limit === "number" ? msg.limit : 50;
|
|
91
397
|
const opts = { cursor: msg.cursor, limit };
|
|
92
398
|
const store = this.stimulationRepository;
|
|
93
|
-
const page = persist.
|
|
94
|
-
(await persist.
|
|
399
|
+
const page = persist.supportsCNSRosterQuery(store) ? await store.queryRoster(filter, opts) : persist.cnsKeysetPage(
|
|
400
|
+
(await persist.loadCNSRosterEntries(store, filter)).map((e) => e.row),
|
|
95
401
|
opts
|
|
96
402
|
);
|
|
97
403
|
this.send(ws, {
|
|
@@ -105,7 +411,7 @@ var CNSDevToolsServer = class {
|
|
|
105
411
|
async handleStimulationsFacets(ws, msg) {
|
|
106
412
|
const filter = this.toRosterFilter(msg.scopeName, msg.filter);
|
|
107
413
|
const store = this.stimulationRepository;
|
|
108
|
-
const facets = persist.
|
|
414
|
+
const facets = persist.supportsCNSRosterQuery(store) ? await store.queryFacets(filter, { metricPath: msg.metricPath }) : persist.computeCNSRosterFacets(await persist.loadCNSRosterEntries(store, filter), {
|
|
109
415
|
metricPath: msg.metricPath
|
|
110
416
|
});
|
|
111
417
|
this.send(ws, {
|
|
@@ -114,17 +420,62 @@ var CNSDevToolsServer = class {
|
|
|
114
420
|
facets
|
|
115
421
|
});
|
|
116
422
|
}
|
|
423
|
+
async handleStimulationLaunch(ws, msg) {
|
|
424
|
+
const newStimulationId = `${msg.entry.collateralName}-${crypto.randomUUID()}`;
|
|
425
|
+
const handled = await this.carryOut({
|
|
426
|
+
type: "launch",
|
|
427
|
+
requestId: msg.requestId,
|
|
428
|
+
stimulationId: newStimulationId,
|
|
429
|
+
stimulationAttemptId: devtoolsDto.buildCNSAttemptId(newStimulationId, 1),
|
|
430
|
+
entry: msg.entry
|
|
431
|
+
});
|
|
432
|
+
if (handled === true) {
|
|
433
|
+
this.send(ws, { type: "stimulation.launch.accepted", requestId: msg.requestId, newStimulationId });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.send(ws, {
|
|
437
|
+
type: "stimulation.launch.rejected",
|
|
438
|
+
requestId: msg.requestId,
|
|
439
|
+
reason: handled === false ? "no runtime accepted the launch" : handled
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async handleStimulationCommand(ws, msg) {
|
|
443
|
+
const handled = await this.carryOut({
|
|
444
|
+
type: "command",
|
|
445
|
+
requestId: msg.requestId,
|
|
446
|
+
stimulationId: msg.stimulationId,
|
|
447
|
+
command: msg.command
|
|
448
|
+
});
|
|
449
|
+
if (handled === true) {
|
|
450
|
+
this.send(ws, { type: "stimulation.command.accepted", requestId: msg.requestId, stimulationId: msg.stimulationId });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.send(ws, {
|
|
454
|
+
type: "stimulation.command.rejected",
|
|
455
|
+
requestId: msg.requestId,
|
|
456
|
+
stimulationId: msg.stimulationId,
|
|
457
|
+
reason: handled === false ? `no runtime accepted the ${msg.command.name} command` : handled
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
async handleScheduledStimulationsList(ws, msg) {
|
|
461
|
+
const reader = this.scheduledStimulationReader;
|
|
462
|
+
const page = reader ? await reader.list(msg.filter ?? {}, msg.cursor ?? null, msg.limit ?? 50) : { rows: [], schedules: [], types: [], nextCursor: null };
|
|
463
|
+
this.send(ws, {
|
|
464
|
+
type: "cns.scheduled.list.result",
|
|
465
|
+
requestId: msg.requestId,
|
|
466
|
+
available: reader !== void 0,
|
|
467
|
+
...page
|
|
468
|
+
});
|
|
469
|
+
}
|
|
117
470
|
async handleStimulationDetail(ws, msg) {
|
|
118
471
|
const attempts = await this.stimulationRepository.getAttempts(
|
|
119
472
|
msg.stimulationId
|
|
120
473
|
);
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
tasks.push(...ts);
|
|
127
|
-
}
|
|
474
|
+
const attemptIds = attempts.map((a) => a.stimulationAttemptId);
|
|
475
|
+
const store = this.stimulationRepository;
|
|
476
|
+
const tasks = store.getTasksFor ? await store.getTasksFor(attemptIds) : (await Promise.all(
|
|
477
|
+
attemptIds.map((id) => store.getTasks(id))
|
|
478
|
+
)).flat();
|
|
128
479
|
this.send(ws, {
|
|
129
480
|
type: "cns.stimulation.detail.result",
|
|
130
481
|
requestId: msg.requestId,
|
|
@@ -142,18 +493,17 @@ var CNSDevToolsServer = class {
|
|
|
142
493
|
const filter = this.toRosterFilter(msg.scopeName, msg.filter);
|
|
143
494
|
const store = this.stimulationRepository;
|
|
144
495
|
const limit = typeof msg.limit === "number" ? msg.limit : 200;
|
|
145
|
-
const page = persist.
|
|
146
|
-
(await persist.
|
|
496
|
+
const page = persist.supportsCNSRosterQuery(store) ? await store.queryRoster(filter, { limit }) : persist.cnsKeysetPage(
|
|
497
|
+
(await persist.loadCNSRosterEntries(store, filter)).map((e) => e.row),
|
|
147
498
|
{ limit }
|
|
148
499
|
);
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
500
|
+
const skip = new Set(
|
|
501
|
+
Array.isArray(msg.skipDetailFor) ? msg.skipDetailFor : []
|
|
502
|
+
);
|
|
503
|
+
const ids = page.rows.map((row) => row.stimulationId).filter((id) => !skip.has(id));
|
|
504
|
+
const attempts = store.getAttemptsFor ? await store.getAttemptsFor(ids) : (await Promise.all(ids.map((id) => store.getAttempts(id)))).flat();
|
|
505
|
+
const attemptIds = attempts.map((a) => a.stimulationAttemptId);
|
|
506
|
+
const tasks = store.getTasksFor ? await store.getTasksFor(attemptIds) : (await Promise.all(attemptIds.map((id) => store.getTasks(id)))).flat();
|
|
157
507
|
this.send(ws, {
|
|
158
508
|
type: "cns.stimulations.snapshot.result",
|
|
159
509
|
requestId: msg.requestId,
|
|
@@ -168,11 +518,27 @@ var CNSDevToolsServer = class {
|
|
|
168
518
|
}
|
|
169
519
|
removeClient(ws) {
|
|
170
520
|
this.uiClients.delete(ws);
|
|
521
|
+
const appId = this.appSockets.get(ws);
|
|
171
522
|
this.appSockets.delete(ws);
|
|
523
|
+
if (appId !== void 0) {
|
|
524
|
+
const sockets = this.appWsByAppId.get(appId);
|
|
525
|
+
if (sockets) {
|
|
526
|
+
sockets.delete(ws);
|
|
527
|
+
if (sockets.size === 0) this.appWsByAppId.delete(appId);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
172
530
|
if (this.uiClients.size === 0) this.stopMetrics();
|
|
173
531
|
}
|
|
174
532
|
async getActiveApps() {
|
|
175
|
-
return this.repository.
|
|
533
|
+
return _CNSDevToolsServer.appsOf(await this.repository.getTopology());
|
|
534
|
+
}
|
|
535
|
+
static appsOf(snapshots) {
|
|
536
|
+
const latest = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const snapshot of snapshots) {
|
|
538
|
+
const known = latest.get(snapshot.appId);
|
|
539
|
+
if (!known || snapshot.timestamp > known.timestamp) latest.set(snapshot.appId, snapshot);
|
|
540
|
+
}
|
|
541
|
+
return [...latest.values()].map((snapshot) => ({ id: snapshot.appId, name: snapshot.appName, version: snapshot.version }));
|
|
176
542
|
}
|
|
177
543
|
stop() {
|
|
178
544
|
this.stopMetrics();
|
|
@@ -201,16 +567,6 @@ var CNSDevToolsServer = class {
|
|
|
201
567
|
}
|
|
202
568
|
async handleTopology(ws, msg) {
|
|
203
569
|
const { appId, cnsId, appName, version, timestamp, neurons, collaterals, dendrites } = msg;
|
|
204
|
-
const now = Date.now();
|
|
205
|
-
const app = {
|
|
206
|
-
id: appId,
|
|
207
|
-
name: appName,
|
|
208
|
-
version,
|
|
209
|
-
connectedAt: now,
|
|
210
|
-
lastSeenAt: now
|
|
211
|
-
};
|
|
212
|
-
await this.repository.upsertApp(app);
|
|
213
|
-
await this.repository.addCnsToApp(appId, cnsId);
|
|
214
570
|
await this.repository.saveTopology({ appId, cnsId, appName, version, timestamp, neurons, collaterals, dendrites });
|
|
215
571
|
this.appSockets.set(ws, appId);
|
|
216
572
|
if (!this.appWsByAppId.has(appId)) this.appWsByAppId.set(appId, /* @__PURE__ */ new Set());
|
|
@@ -220,45 +576,31 @@ var CNSDevToolsServer = class {
|
|
|
220
576
|
requestId: "__broadcast__",
|
|
221
577
|
snapshots: await this.repository.getTopology(cnsId)
|
|
222
578
|
};
|
|
223
|
-
this.broadcast({ type: "app.connected", app, topology: { cnsId, neurons, collaterals, dendrites } });
|
|
579
|
+
this.broadcast({ type: "app.connected", app: { id: appId, name: appName, version }, topology: { cnsId, neurons, collaterals, dendrites } });
|
|
224
580
|
this.broadcastRaw(JSON.stringify(topologyBroadcast));
|
|
225
581
|
}
|
|
226
582
|
// ─── UI client query handlers ─────────────────────────────────────────────
|
|
227
583
|
async handleClientConnect(ws) {
|
|
228
584
|
this.addClient(ws);
|
|
229
|
-
this.send(ws, { type: "apps.result", requestId: "__init__", items: await this.repository.listApps() });
|
|
230
585
|
this.send(ws, {
|
|
231
|
-
type: "
|
|
232
|
-
|
|
233
|
-
|
|
586
|
+
type: "ui.config",
|
|
587
|
+
// Five minutes unless told otherwise: the panel opens on what is
|
|
588
|
+
// happening now, not on everything that ever happened.
|
|
589
|
+
defaultWindowMs: this.ui.defaultWindowMs ?? 5 * 6e4,
|
|
590
|
+
...this.ui
|
|
234
591
|
});
|
|
592
|
+
const snapshots = await this.repository.getTopology();
|
|
593
|
+
this.send(ws, { type: "apps.result", requestId: "__init__", items: _CNSDevToolsServer.appsOf(snapshots) });
|
|
594
|
+
this.send(ws, { type: "topology.result", requestId: "__init__", snapshots });
|
|
235
595
|
}
|
|
236
596
|
async handleAppsQuery(ws, requestId) {
|
|
237
|
-
|
|
238
|
-
this.send(ws, { type: "apps.result", requestId, items });
|
|
597
|
+
this.send(ws, { type: "apps.result", requestId, items: await this.getActiveApps() });
|
|
239
598
|
}
|
|
240
599
|
async handleTopologyQuery(ws, requestId, cnsId, appId) {
|
|
241
600
|
let snapshots = await this.repository.getTopology(cnsId);
|
|
242
601
|
if (appId) snapshots = snapshots.filter((s) => s.appId === appId);
|
|
243
602
|
this.send(ws, { type: "topology.result", requestId, snapshots });
|
|
244
603
|
}
|
|
245
|
-
async handleReplayStart(ws, msg) {
|
|
246
|
-
const appId = msg.appId ?? (msg.cnsId ? await this.repository.findAppByCns(msg.cnsId) : void 0);
|
|
247
|
-
const appSockets = appId ? this.appWsByAppId.get(appId) : void 0;
|
|
248
|
-
if (!appSockets || appSockets.size === 0) {
|
|
249
|
-
this.send(ws, { type: "replay.rejected", replayId: msg.replayId, reason: "App not connected" });
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const newStimulationId = `${msg.stimulationId}-replay-${Date.now()}`;
|
|
253
|
-
this.send(ws, { type: "replay.accepted", replayId: msg.replayId, newStimulationId });
|
|
254
|
-
const payload = JSON.stringify(msg);
|
|
255
|
-
for (const appWs of appSockets) {
|
|
256
|
-
try {
|
|
257
|
-
appWs.send(payload);
|
|
258
|
-
} catch {
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
604
|
// ─── Resumable actions (retry / clone), Phase 2b-2 ──────────────────────────────
|
|
263
605
|
//
|
|
264
606
|
// The UI's request is thin (a stimulationId). The CNS + name→ref registry live in
|
|
@@ -268,8 +610,22 @@ var CNSDevToolsServer = class {
|
|
|
268
610
|
/** Resolve the app that owns a stimulation's scope, or reject if unreachable. */
|
|
269
611
|
async resolveScopeApp(scopeName) {
|
|
270
612
|
const appId = scopeName ? await this.repository.findAppByCns(scopeName) : void 0;
|
|
271
|
-
|
|
272
|
-
|
|
613
|
+
return appId ? this.liveSockets(appId) : void 0;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Sockets of `appId` that can still receive a command, or `undefined` when
|
|
617
|
+
* none can. `close` is not always dispatched before we route (a half-dead
|
|
618
|
+
* socket, an embedding that never calls `removeClient`), so being registered
|
|
619
|
+
* is not proof of being reachable — readyState is.
|
|
620
|
+
*/
|
|
621
|
+
liveSockets(appId) {
|
|
622
|
+
const sockets = this.appWsByAppId.get(appId);
|
|
623
|
+
if (!sockets || sockets.size === 0) return void 0;
|
|
624
|
+
const open = /* @__PURE__ */ new Set();
|
|
625
|
+
for (const ws of sockets) {
|
|
626
|
+
if (ws.readyState === 1) open.add(ws);
|
|
627
|
+
}
|
|
628
|
+
return open.size > 0 ? open : void 0;
|
|
273
629
|
}
|
|
274
630
|
async handleStimulationRetry(ws, msg) {
|
|
275
631
|
const reject = (reason) => this.send(ws, {
|
|
@@ -282,19 +638,15 @@ var CNSDevToolsServer = class {
|
|
|
282
638
|
if (!stim) return reject("unknown stimulation");
|
|
283
639
|
if (stim.progress.tasks.length === 0)
|
|
284
640
|
return reject("nothing to resume (empty frontier)");
|
|
285
|
-
const appSockets = await this.resolveScopeApp(stim.scopeName);
|
|
286
|
-
if (!appSockets) return reject("app not connected");
|
|
287
641
|
const attempts = await this.stimulationRepository.getAttempts(msg.stimulationId);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const payload = JSON.stringify({
|
|
297
|
-
type: "cns.stimulation.resume",
|
|
642
|
+
const latest = attempts.reduce(
|
|
643
|
+
(max, a) => !max || a.attemptNumber > max.attemptNumber ? a : max,
|
|
644
|
+
void 0
|
|
645
|
+
);
|
|
646
|
+
const nextAttempt = (latest?.attemptNumber ?? 0) + 1;
|
|
647
|
+
const stimulationAttemptId = devtoolsDto.buildCNSAttemptId(msg.stimulationId, nextAttempt);
|
|
648
|
+
const handled = await this.carryOut({
|
|
649
|
+
type: "resume",
|
|
298
650
|
requestId: msg.requestId,
|
|
299
651
|
scopeName: stim.scopeName,
|
|
300
652
|
stimulationId: msg.stimulationId,
|
|
@@ -302,14 +654,21 @@ var CNSDevToolsServer = class {
|
|
|
302
654
|
attemptNumber: nextAttempt,
|
|
303
655
|
entry: stim.entry,
|
|
304
656
|
progress: stim.progress,
|
|
657
|
+
// Provenance: which attempt this retry continues from.
|
|
658
|
+
replayOf: latest?.stimulationAttemptId,
|
|
305
659
|
options: msg.options
|
|
306
660
|
});
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
661
|
+
if (handled !== true) {
|
|
662
|
+
return reject(
|
|
663
|
+
handled === false ? "no runtime accepted the retry" : `retry failed: ${handled}`
|
|
664
|
+
);
|
|
312
665
|
}
|
|
666
|
+
this.send(ws, {
|
|
667
|
+
type: "stimulation.retry.accepted",
|
|
668
|
+
requestId: msg.requestId,
|
|
669
|
+
stimulationId: msg.stimulationId,
|
|
670
|
+
newStimulationAttemptId: stimulationAttemptId
|
|
671
|
+
});
|
|
313
672
|
}
|
|
314
673
|
async handleStimulationClone(ws, msg) {
|
|
315
674
|
const reject = (reason) => this.send(ws, {
|
|
@@ -320,32 +679,39 @@ var CNSDevToolsServer = class {
|
|
|
320
679
|
});
|
|
321
680
|
const stim = await this.stimulationRepository.getStimulation(msg.stimulationId);
|
|
322
681
|
if (!stim) return reject("unknown stimulation");
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const payload = JSON.stringify({
|
|
335
|
-
type: "cns.stimulation.launch",
|
|
682
|
+
const newStimulationId = devtoolsDto.buildCNSCloneId(msg.stimulationId);
|
|
683
|
+
const stimulationAttemptId = devtoolsDto.buildCNSAttemptId(newStimulationId, 1);
|
|
684
|
+
const sourceAttempts = await this.stimulationRepository.getAttempts(
|
|
685
|
+
msg.stimulationId
|
|
686
|
+
);
|
|
687
|
+
const replayOf = sourceAttempts.reduce(
|
|
688
|
+
(max, a) => !max || a.attemptNumber > max.attemptNumber ? a : max,
|
|
689
|
+
void 0
|
|
690
|
+
)?.stimulationAttemptId;
|
|
691
|
+
const handled = await this.carryOut({
|
|
692
|
+
type: "launch",
|
|
336
693
|
requestId: msg.requestId,
|
|
337
694
|
scopeName: stim.scopeName,
|
|
338
695
|
stimulationId: newStimulationId,
|
|
339
696
|
stimulationAttemptId,
|
|
340
697
|
entry: stim.entry,
|
|
698
|
+
// A clone gets a NEW stimulationId, so this is the ONLY link back to
|
|
699
|
+
// the run it came from.
|
|
700
|
+
replayOf,
|
|
341
701
|
options: msg.options
|
|
342
702
|
});
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
703
|
+
if (handled !== true) {
|
|
704
|
+
return reject(
|
|
705
|
+
handled === false ? "no runtime accepted the clone" : `clone failed: ${handled}`
|
|
706
|
+
);
|
|
348
707
|
}
|
|
708
|
+
this.send(ws, {
|
|
709
|
+
type: "stimulation.clone.accepted",
|
|
710
|
+
requestId: msg.requestId,
|
|
711
|
+
stimulationId: msg.stimulationId,
|
|
712
|
+
newStimulationId,
|
|
713
|
+
newStimulationAttemptId: stimulationAttemptId
|
|
714
|
+
});
|
|
349
715
|
}
|
|
350
716
|
// ─── Broadcast helpers ────────────────────────────────────────────────────
|
|
351
717
|
broadcast(message) {
|
|
@@ -394,6 +760,8 @@ var CNSDevToolsServer = class {
|
|
|
394
760
|
}
|
|
395
761
|
};
|
|
396
762
|
|
|
763
|
+
exports.CNSDevToolsPollingTransport = CNSDevToolsPollingTransport;
|
|
397
764
|
exports.CNSDevToolsServer = CNSDevToolsServer;
|
|
765
|
+
exports.startCNSDevTools = startCNSDevTools;
|
|
398
766
|
//# sourceMappingURL=index.cjs.map
|
|
399
767
|
//# sourceMappingURL=index.cjs.map
|