@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 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
- var CNSDevToolsServer = class {
8
- constructor(repository, stimulationRepository = new persist.CNSInMemoryStimulationRepository()) {
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.CNSDTOAppBatchMessageSchema.safeParse(data);
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.CNSDTOClientMessageSchema.safeParse(data);
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.query":
62
- await this.handleRunsQuery(ws, msg);
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
- * Name-based observability (Phase 2b-3): project the durable
68
- * `ICNSStimulationRepository` into the run/attempt/task roster the panel
69
- * renders. This replaces the legacy id-based `stimulations.query`/`hops.query`.
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
- async handleRunsQuery(ws, msg) {
72
- const stims = await this.stimulationRepository.listStimulations(
73
- msg.scopeName ? { scopeName: msg.scopeName } : void 0
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
- const runs = [];
76
- for (const stim of stims) {
77
- const attempts = await this.stimulationRepository.getAttempts(
78
- stim.stimulationId
79
- );
80
- const attemptViews = [];
81
- for (const a of attempts) {
82
- const tasks = await this.stimulationRepository.getTasks(
83
- a.stimulationAttemptId
84
- );
85
- attemptViews.push({
86
- attemptNumber: a.attemptNumber,
87
- status: a.status,
88
- hopCount: a.hopCount,
89
- startedAt: a.startedAt,
90
- completedAt: a.completedAt ?? null,
91
- tasks: tasks.map((t) => ({
92
- index: t.index,
93
- neuronName: t.neuronName,
94
- dendriteCollateralName: t.dendriteCollateralName,
95
- status: t.status,
96
- output: t.output ? {
97
- collateralName: t.output.collateralName,
98
- payload: t.output.payload
99
- } : null,
100
- error: t.error ?? null,
101
- startedAt: t.startedAt,
102
- duration: t.duration ?? null
103
- }))
104
- });
105
- }
106
- runs.push({
107
- runId: stim.stimulationId,
108
- status: stim.status,
109
- scopeName: stim.scopeName,
110
- entry: {
111
- collateralName: stim.entry.collateralName,
112
- payload: stim.entry.payload
113
- },
114
- frontier: stim.progress.tasks.map((t) => t.neuronName),
115
- attempts: attemptViews
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: "cns.stimulations.result",
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
- runs
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.listApps();
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 durable model ──
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 durable store, for tests/integrations that want to read it back. */
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: "topology.result",
191
- requestId: "__init__",
192
- snapshots: await this.repository.getTopology()
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
- const items = await this.repository.listApps();
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
- async handleReplayStart(ws, msg) {
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 durable store (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
- const sockets = appId ? this.appWsByAppId.get(appId) : void 0;
231
- return sockets && sockets.size > 0 ? sockets : void 0;
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 nextAttempt = attempts.length + 1;
248
- const stimulationAttemptId = `${msg.stimulationId}#${nextAttempt}`;
249
- this.send(ws, {
250
- type: "stimulation.retry.accepted",
251
- requestId: msg.requestId,
252
- stimulationId: msg.stimulationId,
253
- newStimulationAttemptId: stimulationAttemptId
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
- for (const appWs of appSockets) {
267
- try {
268
- appWs.send(payload);
269
- } catch {
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 appSockets = await this.resolveScopeApp(stim.scopeName);
283
- if (!appSockets) return reject("app not connected");
284
- const newStimulationId = `${msg.stimulationId}-clone-${Date.now()}`;
285
- const stimulationAttemptId = `${newStimulationId}#1`;
286
- this.send(ws, {
287
- type: "stimulation.clone.accepted",
288
- requestId: msg.requestId,
289
- stimulationId: msg.stimulationId,
290
- newStimulationId,
291
- newStimulationAttemptId: stimulationAttemptId
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
- for (const appWs of appSockets) {
303
- try {
304
- appWs.send(payload);
305
- } catch {
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