@cnstra/devtools-server 2.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 +544 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +96 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +545 -138
- 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 +42 -6
- package/dist/start.d.ts.map +1 -1
- package/package.json +80 -73
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) {
|
|
@@ -29,14 +329,14 @@ var CNSDevToolsServer = class {
|
|
|
29
329
|
if (!data || typeof data !== "object" || !("type" in data)) return;
|
|
30
330
|
const type = data.type;
|
|
31
331
|
if (type === "batch") {
|
|
32
|
-
const result = devtoolsDto.
|
|
332
|
+
const result = devtoolsDto.CNSAppBatchMessageDTOSchema.safeParse(data);
|
|
33
333
|
if (!result.success) return;
|
|
34
334
|
for (const item of result.data.items) {
|
|
35
335
|
await this.handleAppBatchItem(ws, item);
|
|
36
336
|
}
|
|
37
337
|
return;
|
|
38
338
|
}
|
|
39
|
-
const clientResult = devtoolsDto.
|
|
339
|
+
const clientResult = devtoolsDto.CNSClientMessageDTOSchema.safeParse(data);
|
|
40
340
|
if (!clientResult.success) return;
|
|
41
341
|
const msg = clientResult.data;
|
|
42
342
|
switch (msg.type) {
|
|
@@ -49,76 +349,167 @@ 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;
|
|
58
355
|
case "stimulation.clone":
|
|
59
356
|
await this.handleStimulationClone(ws, msg);
|
|
60
357
|
break;
|
|
61
|
-
case "cns.stimulations.
|
|
62
|
-
await this.
|
|
358
|
+
case "cns.stimulations.list":
|
|
359
|
+
await this.handleStimulationsList(ws, msg);
|
|
360
|
+
break;
|
|
361
|
+
case "cns.stimulations.facets":
|
|
362
|
+
await this.handleStimulationsFacets(ws, msg);
|
|
363
|
+
break;
|
|
364
|
+
case "cns.stimulations.snapshot":
|
|
365
|
+
await this.handleStimulationsSnapshot(ws, msg);
|
|
366
|
+
break;
|
|
367
|
+
case "cns.stimulation.detail":
|
|
368
|
+
await this.handleStimulationDetail(ws, msg);
|
|
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);
|
|
63
378
|
break;
|
|
64
379
|
}
|
|
65
380
|
}
|
|
66
381
|
/**
|
|
67
|
-
*
|
|
68
|
-
* `
|
|
69
|
-
*
|
|
382
|
+
* Explorer protocol (bucket-shaped). The roster row = resumable state minus
|
|
383
|
+
* `progress`, plus envelope summaries derived from the run's attempts.
|
|
384
|
+
*
|
|
385
|
+
* Pushdown: if the store implements `ICNSStimulationRosterQuery` (e.g. the
|
|
386
|
+
* Postgres store), the filter + keyset + GROUP BY run INSIDE it (SQL). Else we
|
|
387
|
+
* fall back to the shared reference derivation over the reader — identical
|
|
388
|
+
* results, only the work moves. Roster shapes are structurally the wire DTOs,
|
|
389
|
+
* so no mapping is needed at the boundary.
|
|
70
390
|
*/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
391
|
+
toRosterFilter(scopeName, filter) {
|
|
392
|
+
return { ...filter ?? {}, ...scopeName ? { scopeName } : {} };
|
|
393
|
+
}
|
|
394
|
+
async handleStimulationsList(ws, msg) {
|
|
395
|
+
const filter = this.toRosterFilter(msg.scopeName, msg.filter);
|
|
396
|
+
const limit = typeof msg.limit === "number" ? msg.limit : 50;
|
|
397
|
+
const opts = { cursor: msg.cursor, limit };
|
|
398
|
+
const store = this.stimulationRepository;
|
|
399
|
+
const page = persist.supportsCNSRosterQuery(store) ? await store.queryRoster(filter, opts) : persist.cnsKeysetPage(
|
|
400
|
+
(await persist.loadCNSRosterEntries(store, filter)).map((e) => e.row),
|
|
401
|
+
opts
|
|
74
402
|
);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
403
|
+
this.send(ws, {
|
|
404
|
+
type: "cns.stimulations.list.result",
|
|
405
|
+
requestId: msg.requestId,
|
|
406
|
+
stimulations: page.rows,
|
|
407
|
+
nextCursor: page.nextCursor,
|
|
408
|
+
total: page.total
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
async handleStimulationsFacets(ws, msg) {
|
|
412
|
+
const filter = this.toRosterFilter(msg.scopeName, msg.filter);
|
|
413
|
+
const store = this.stimulationRepository;
|
|
414
|
+
const facets = persist.supportsCNSRosterQuery(store) ? await store.queryFacets(filter, { metricPath: msg.metricPath }) : persist.computeCNSRosterFacets(await persist.loadCNSRosterEntries(store, filter), {
|
|
415
|
+
metricPath: msg.metricPath
|
|
416
|
+
});
|
|
417
|
+
this.send(ws, {
|
|
418
|
+
type: "cns.stimulations.facets.result",
|
|
419
|
+
requestId: msg.requestId,
|
|
420
|
+
facets
|
|
421
|
+
});
|
|
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;
|
|
117
452
|
}
|
|
118
453
|
this.send(ws, {
|
|
119
|
-
type: "
|
|
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",
|
|
120
465
|
requestId: msg.requestId,
|
|
121
|
-
|
|
466
|
+
available: reader !== void 0,
|
|
467
|
+
...page
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
async handleStimulationDetail(ws, msg) {
|
|
471
|
+
const attempts = await this.stimulationRepository.getAttempts(
|
|
472
|
+
msg.stimulationId
|
|
473
|
+
);
|
|
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();
|
|
479
|
+
this.send(ws, {
|
|
480
|
+
type: "cns.stimulation.detail.result",
|
|
481
|
+
requestId: msg.requestId,
|
|
482
|
+
attempts,
|
|
483
|
+
tasks
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The topology/signal views' bulk feed: a bounded, newest-first set of runs as
|
|
488
|
+
* FLAT buckets (stimulations + attempts + tasks) the panel joins client-side by
|
|
489
|
+
* id. Replaces the retired nested `cns.stimulations.query` tree — same data, no
|
|
490
|
+
* server-side join, no client-side flatten.
|
|
491
|
+
*/
|
|
492
|
+
async handleStimulationsSnapshot(ws, msg) {
|
|
493
|
+
const filter = this.toRosterFilter(msg.scopeName, msg.filter);
|
|
494
|
+
const store = this.stimulationRepository;
|
|
495
|
+
const limit = typeof msg.limit === "number" ? msg.limit : 200;
|
|
496
|
+
const page = persist.supportsCNSRosterQuery(store) ? await store.queryRoster(filter, { limit }) : persist.cnsKeysetPage(
|
|
497
|
+
(await persist.loadCNSRosterEntries(store, filter)).map((e) => e.row),
|
|
498
|
+
{ limit }
|
|
499
|
+
);
|
|
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();
|
|
507
|
+
this.send(ws, {
|
|
508
|
+
type: "cns.stimulations.snapshot.result",
|
|
509
|
+
requestId: msg.requestId,
|
|
510
|
+
stimulations: page.rows,
|
|
511
|
+
attempts,
|
|
512
|
+
tasks
|
|
122
513
|
});
|
|
123
514
|
}
|
|
124
515
|
addClient(ws) {
|
|
@@ -127,11 +518,27 @@ var CNSDevToolsServer = class {
|
|
|
127
518
|
}
|
|
128
519
|
removeClient(ws) {
|
|
129
520
|
this.uiClients.delete(ws);
|
|
521
|
+
const appId = this.appSockets.get(ws);
|
|
130
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
|
+
}
|
|
131
530
|
if (this.uiClients.size === 0) this.stopMetrics();
|
|
132
531
|
}
|
|
133
532
|
async getActiveApps() {
|
|
134
|
-
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 }));
|
|
135
542
|
}
|
|
136
543
|
stop() {
|
|
137
544
|
this.stopMetrics();
|
|
@@ -142,7 +549,7 @@ var CNSDevToolsServer = class {
|
|
|
142
549
|
case "topology":
|
|
143
550
|
await this.handleTopology(ws, item);
|
|
144
551
|
break;
|
|
145
|
-
// ── Name-based
|
|
552
|
+
// ── Name-based resumable model ──
|
|
146
553
|
case "cns.stimulation":
|
|
147
554
|
await this.stimulationRepository.saveStimulation(item.data);
|
|
148
555
|
break;
|
|
@@ -154,22 +561,12 @@ var CNSDevToolsServer = class {
|
|
|
154
561
|
break;
|
|
155
562
|
}
|
|
156
563
|
}
|
|
157
|
-
/** The name-based
|
|
564
|
+
/** The name-based resumable store, for tests/integrations that want to read it back. */
|
|
158
565
|
getStimulationRepository() {
|
|
159
566
|
return this.stimulationRepository;
|
|
160
567
|
}
|
|
161
568
|
async handleTopology(ws, msg) {
|
|
162
569
|
const { appId, cnsId, appName, version, timestamp, neurons, collaterals, dendrites } = msg;
|
|
163
|
-
const now = Date.now();
|
|
164
|
-
const app = {
|
|
165
|
-
id: appId,
|
|
166
|
-
name: appName,
|
|
167
|
-
version,
|
|
168
|
-
connectedAt: now,
|
|
169
|
-
lastSeenAt: now
|
|
170
|
-
};
|
|
171
|
-
await this.repository.upsertApp(app);
|
|
172
|
-
await this.repository.addCnsToApp(appId, cnsId);
|
|
173
570
|
await this.repository.saveTopology({ appId, cnsId, appName, version, timestamp, neurons, collaterals, dendrites });
|
|
174
571
|
this.appSockets.set(ws, appId);
|
|
175
572
|
if (!this.appWsByAppId.has(appId)) this.appWsByAppId.set(appId, /* @__PURE__ */ new Set());
|
|
@@ -179,56 +576,56 @@ var CNSDevToolsServer = class {
|
|
|
179
576
|
requestId: "__broadcast__",
|
|
180
577
|
snapshots: await this.repository.getTopology(cnsId)
|
|
181
578
|
};
|
|
182
|
-
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 } });
|
|
183
580
|
this.broadcastRaw(JSON.stringify(topologyBroadcast));
|
|
184
581
|
}
|
|
185
582
|
// ─── UI client query handlers ─────────────────────────────────────────────
|
|
186
583
|
async handleClientConnect(ws) {
|
|
187
584
|
this.addClient(ws);
|
|
188
|
-
this.send(ws, { type: "apps.result", requestId: "__init__", items: await this.repository.listApps() });
|
|
189
585
|
this.send(ws, {
|
|
190
|
-
type: "
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
193
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 });
|
|
194
595
|
}
|
|
195
596
|
async handleAppsQuery(ws, requestId) {
|
|
196
|
-
|
|
197
|
-
this.send(ws, { type: "apps.result", requestId, items });
|
|
597
|
+
this.send(ws, { type: "apps.result", requestId, items: await this.getActiveApps() });
|
|
198
598
|
}
|
|
199
599
|
async handleTopologyQuery(ws, requestId, cnsId, appId) {
|
|
200
600
|
let snapshots = await this.repository.getTopology(cnsId);
|
|
201
601
|
if (appId) snapshots = snapshots.filter((s) => s.appId === appId);
|
|
202
602
|
this.send(ws, { type: "topology.result", requestId, snapshots });
|
|
203
603
|
}
|
|
204
|
-
|
|
205
|
-
const appId = msg.appId ?? (msg.cnsId ? await this.repository.findAppByCns(msg.cnsId) : void 0);
|
|
206
|
-
const appSockets = appId ? this.appWsByAppId.get(appId) : void 0;
|
|
207
|
-
if (!appSockets || appSockets.size === 0) {
|
|
208
|
-
this.send(ws, { type: "replay.rejected", replayId: msg.replayId, reason: "App not connected" });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const newStimulationId = `${msg.stimulationId}-replay-${Date.now()}`;
|
|
212
|
-
this.send(ws, { type: "replay.accepted", replayId: msg.replayId, newStimulationId });
|
|
213
|
-
const payload = JSON.stringify(msg);
|
|
214
|
-
for (const appWs of appSockets) {
|
|
215
|
-
try {
|
|
216
|
-
appWs.send(payload);
|
|
217
|
-
} catch {
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// ─── Durable actions (retry / clone), Phase 2b-2 ──────────────────────────────
|
|
604
|
+
// ─── Resumable actions (retry / clone), Phase 2b-2 ──────────────────────────────
|
|
222
605
|
//
|
|
223
606
|
// The UI's request is thin (a stimulationId). The CNS + name→ref registry live in
|
|
224
|
-
// the APP, so the server can't execute it: it ENRICHES from the
|
|
607
|
+
// the APP, so the server can't execute it: it ENRICHES from the resumable store (the
|
|
225
608
|
// stored entry + progress + a server-assigned attempt identity) and forwards a
|
|
226
609
|
// resume/launch command to the owning app, routed by scopeName (= the cns id).
|
|
227
610
|
/** Resolve the app that owns a stimulation's scope, or reject if unreachable. */
|
|
228
611
|
async resolveScopeApp(scopeName) {
|
|
229
612
|
const appId = scopeName ? await this.repository.findAppByCns(scopeName) : void 0;
|
|
230
|
-
|
|
231
|
-
|
|
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;
|
|
232
629
|
}
|
|
233
630
|
async handleStimulationRetry(ws, msg) {
|
|
234
631
|
const reject = (reason) => this.send(ws, {
|
|
@@ -241,19 +638,15 @@ var CNSDevToolsServer = class {
|
|
|
241
638
|
if (!stim) return reject("unknown stimulation");
|
|
242
639
|
if (stim.progress.tasks.length === 0)
|
|
243
640
|
return reject("nothing to resume (empty frontier)");
|
|
244
|
-
const appSockets = await this.resolveScopeApp(stim.scopeName);
|
|
245
|
-
if (!appSockets) return reject("app not connected");
|
|
246
641
|
const attempts = await this.stimulationRepository.getAttempts(msg.stimulationId);
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const payload = JSON.stringify({
|
|
256
|
-
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",
|
|
257
650
|
requestId: msg.requestId,
|
|
258
651
|
scopeName: stim.scopeName,
|
|
259
652
|
stimulationId: msg.stimulationId,
|
|
@@ -261,14 +654,21 @@ var CNSDevToolsServer = class {
|
|
|
261
654
|
attemptNumber: nextAttempt,
|
|
262
655
|
entry: stim.entry,
|
|
263
656
|
progress: stim.progress,
|
|
657
|
+
// Provenance: which attempt this retry continues from.
|
|
658
|
+
replayOf: latest?.stimulationAttemptId,
|
|
264
659
|
options: msg.options
|
|
265
660
|
});
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
661
|
+
if (handled !== true) {
|
|
662
|
+
return reject(
|
|
663
|
+
handled === false ? "no runtime accepted the retry" : `retry failed: ${handled}`
|
|
664
|
+
);
|
|
271
665
|
}
|
|
666
|
+
this.send(ws, {
|
|
667
|
+
type: "stimulation.retry.accepted",
|
|
668
|
+
requestId: msg.requestId,
|
|
669
|
+
stimulationId: msg.stimulationId,
|
|
670
|
+
newStimulationAttemptId: stimulationAttemptId
|
|
671
|
+
});
|
|
272
672
|
}
|
|
273
673
|
async handleStimulationClone(ws, msg) {
|
|
274
674
|
const reject = (reason) => this.send(ws, {
|
|
@@ -279,32 +679,39 @@ var CNSDevToolsServer = class {
|
|
|
279
679
|
});
|
|
280
680
|
const stim = await this.stimulationRepository.getStimulation(msg.stimulationId);
|
|
281
681
|
if (!stim) return reject("unknown stimulation");
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const payload = JSON.stringify({
|
|
294
|
-
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",
|
|
295
693
|
requestId: msg.requestId,
|
|
296
694
|
scopeName: stim.scopeName,
|
|
297
695
|
stimulationId: newStimulationId,
|
|
298
696
|
stimulationAttemptId,
|
|
299
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,
|
|
300
701
|
options: msg.options
|
|
301
702
|
});
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
703
|
+
if (handled !== true) {
|
|
704
|
+
return reject(
|
|
705
|
+
handled === false ? "no runtime accepted the clone" : `clone failed: ${handled}`
|
|
706
|
+
);
|
|
307
707
|
}
|
|
708
|
+
this.send(ws, {
|
|
709
|
+
type: "stimulation.clone.accepted",
|
|
710
|
+
requestId: msg.requestId,
|
|
711
|
+
stimulationId: msg.stimulationId,
|
|
712
|
+
newStimulationId,
|
|
713
|
+
newStimulationAttemptId: stimulationAttemptId
|
|
714
|
+
});
|
|
308
715
|
}
|
|
309
716
|
// ─── Broadcast helpers ────────────────────────────────────────────────────
|
|
310
717
|
broadcast(message) {
|
|
@@ -353,6 +760,8 @@ var CNSDevToolsServer = class {
|
|
|
353
760
|
}
|
|
354
761
|
};
|
|
355
762
|
|
|
763
|
+
exports.CNSDevToolsPollingTransport = CNSDevToolsPollingTransport;
|
|
356
764
|
exports.CNSDevToolsServer = CNSDevToolsServer;
|
|
765
|
+
exports.startCNSDevTools = startCNSDevTools;
|
|
357
766
|
//# sourceMappingURL=index.cjs.map
|
|
358
767
|
//# sourceMappingURL=index.cjs.map
|