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