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