@cnstra/devtools-server 3.0.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs 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) {
@@ -49,9 +349,6 @@ var CNSDevToolsServer = class {
49
349
  case "topology.query":
50
350
  await this.handleTopologyQuery(ws, msg.requestId, msg.cnsId, msg.appId);
51
351
  break;
52
- case "replay.start":
53
- await this.handleReplayStart(ws, msg);
54
- break;
55
352
  case "stimulation.retry":
56
353
  await this.handleStimulationRetry(ws, msg);
57
354
  break;
@@ -70,6 +367,15 @@ var CNSDevToolsServer = class {
70
367
  case "cns.stimulation.detail":
71
368
  await this.handleStimulationDetail(ws, msg);
72
369
  break;
370
+ case "cns.scheduled.list":
371
+ await this.handleScheduledStimulationsList(ws, msg);
372
+ break;
373
+ case "stimulation.command":
374
+ await this.handleStimulationCommand(ws, msg);
375
+ break;
376
+ case "stimulation.launch":
377
+ await this.handleStimulationLaunch(ws, msg);
378
+ break;
73
379
  }
74
380
  }
75
381
  /**
@@ -90,8 +396,8 @@ var CNSDevToolsServer = class {
90
396
  const limit = typeof msg.limit === "number" ? msg.limit : 50;
91
397
  const opts = { cursor: msg.cursor, limit };
92
398
  const store = this.stimulationRepository;
93
- const page = persist.supportsRosterQuery(store) ? await store.queryRoster(filter, opts) : persist.keysetPage(
94
- (await persist.loadRosterEntries(store, filter)).map((e) => e.row),
399
+ const page = persist.supportsCNSRosterQuery(store) ? await store.queryRoster(filter, opts) : persist.cnsKeysetPage(
400
+ (await persist.loadCNSRosterEntries(store, filter)).map((e) => e.row),
95
401
  opts
96
402
  );
97
403
  this.send(ws, {
@@ -105,7 +411,7 @@ var CNSDevToolsServer = class {
105
411
  async handleStimulationsFacets(ws, msg) {
106
412
  const filter = this.toRosterFilter(msg.scopeName, msg.filter);
107
413
  const store = this.stimulationRepository;
108
- const facets = persist.supportsRosterQuery(store) ? await store.queryFacets(filter, { metricPath: msg.metricPath }) : persist.computeRosterFacets(await persist.loadRosterEntries(store, filter), {
414
+ const facets = persist.supportsCNSRosterQuery(store) ? await store.queryFacets(filter, { metricPath: msg.metricPath }) : persist.computeCNSRosterFacets(await persist.loadCNSRosterEntries(store, filter), {
109
415
  metricPath: msg.metricPath
110
416
  });
111
417
  this.send(ws, {
@@ -114,17 +420,62 @@ var CNSDevToolsServer = class {
114
420
  facets
115
421
  });
116
422
  }
423
+ async handleStimulationLaunch(ws, msg) {
424
+ const newStimulationId = `${msg.entry.collateralName}-${crypto.randomUUID()}`;
425
+ const handled = await this.carryOut({
426
+ type: "launch",
427
+ requestId: msg.requestId,
428
+ stimulationId: newStimulationId,
429
+ stimulationAttemptId: devtoolsDto.buildCNSAttemptId(newStimulationId, 1),
430
+ entry: msg.entry
431
+ });
432
+ if (handled === true) {
433
+ this.send(ws, { type: "stimulation.launch.accepted", requestId: msg.requestId, newStimulationId });
434
+ return;
435
+ }
436
+ this.send(ws, {
437
+ type: "stimulation.launch.rejected",
438
+ requestId: msg.requestId,
439
+ reason: handled === false ? "no runtime accepted the launch" : handled
440
+ });
441
+ }
442
+ async handleStimulationCommand(ws, msg) {
443
+ const handled = await this.carryOut({
444
+ type: "command",
445
+ requestId: msg.requestId,
446
+ stimulationId: msg.stimulationId,
447
+ command: msg.command
448
+ });
449
+ if (handled === true) {
450
+ this.send(ws, { type: "stimulation.command.accepted", requestId: msg.requestId, stimulationId: msg.stimulationId });
451
+ return;
452
+ }
453
+ this.send(ws, {
454
+ type: "stimulation.command.rejected",
455
+ requestId: msg.requestId,
456
+ stimulationId: msg.stimulationId,
457
+ reason: handled === false ? `no runtime accepted the ${msg.command.name} command` : handled
458
+ });
459
+ }
460
+ async handleScheduledStimulationsList(ws, msg) {
461
+ const reader = this.scheduledStimulationReader;
462
+ const page = reader ? await reader.list(msg.filter ?? {}, msg.cursor ?? null, msg.limit ?? 50) : { rows: [], schedules: [], types: [], nextCursor: null };
463
+ this.send(ws, {
464
+ type: "cns.scheduled.list.result",
465
+ requestId: msg.requestId,
466
+ available: reader !== void 0,
467
+ ...page
468
+ });
469
+ }
117
470
  async handleStimulationDetail(ws, msg) {
118
471
  const attempts = await this.stimulationRepository.getAttempts(
119
472
  msg.stimulationId
120
473
  );
121
- const tasks = [];
122
- for (const a of attempts) {
123
- const ts = await this.stimulationRepository.getTasks(
124
- a.stimulationAttemptId
125
- );
126
- tasks.push(...ts);
127
- }
474
+ const attemptIds = attempts.map((a) => a.stimulationAttemptId);
475
+ const store = this.stimulationRepository;
476
+ const tasks = store.getTasksFor ? await store.getTasksFor(attemptIds) : (await Promise.all(
477
+ attemptIds.map((id) => store.getTasks(id))
478
+ )).flat();
128
479
  this.send(ws, {
129
480
  type: "cns.stimulation.detail.result",
130
481
  requestId: msg.requestId,
@@ -142,18 +493,17 @@ var CNSDevToolsServer = class {
142
493
  const filter = this.toRosterFilter(msg.scopeName, msg.filter);
143
494
  const store = this.stimulationRepository;
144
495
  const limit = typeof msg.limit === "number" ? msg.limit : 200;
145
- const page = persist.supportsRosterQuery(store) ? await store.queryRoster(filter, { limit }) : persist.keysetPage(
146
- (await persist.loadRosterEntries(store, filter)).map((e) => e.row),
496
+ const page = persist.supportsCNSRosterQuery(store) ? await store.queryRoster(filter, { limit }) : persist.cnsKeysetPage(
497
+ (await persist.loadCNSRosterEntries(store, filter)).map((e) => e.row),
147
498
  { limit }
148
499
  );
149
- const attempts = [];
150
- const tasks = [];
151
- for (const row of page.rows) {
152
- const as = await store.getAttempts(row.stimulationId);
153
- attempts.push(...as);
154
- for (const a of as)
155
- tasks.push(...await store.getTasks(a.stimulationAttemptId));
156
- }
500
+ const skip = new Set(
501
+ Array.isArray(msg.skipDetailFor) ? msg.skipDetailFor : []
502
+ );
503
+ const ids = page.rows.map((row) => row.stimulationId).filter((id) => !skip.has(id));
504
+ const attempts = store.getAttemptsFor ? await store.getAttemptsFor(ids) : (await Promise.all(ids.map((id) => store.getAttempts(id)))).flat();
505
+ const attemptIds = attempts.map((a) => a.stimulationAttemptId);
506
+ const tasks = store.getTasksFor ? await store.getTasksFor(attemptIds) : (await Promise.all(attemptIds.map((id) => store.getTasks(id)))).flat();
157
507
  this.send(ws, {
158
508
  type: "cns.stimulations.snapshot.result",
159
509
  requestId: msg.requestId,
@@ -168,11 +518,27 @@ var CNSDevToolsServer = class {
168
518
  }
169
519
  removeClient(ws) {
170
520
  this.uiClients.delete(ws);
521
+ const appId = this.appSockets.get(ws);
171
522
  this.appSockets.delete(ws);
523
+ if (appId !== void 0) {
524
+ const sockets = this.appWsByAppId.get(appId);
525
+ if (sockets) {
526
+ sockets.delete(ws);
527
+ if (sockets.size === 0) this.appWsByAppId.delete(appId);
528
+ }
529
+ }
172
530
  if (this.uiClients.size === 0) this.stopMetrics();
173
531
  }
174
532
  async getActiveApps() {
175
- return this.repository.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 }));
176
542
  }
177
543
  stop() {
178
544
  this.stopMetrics();
@@ -201,16 +567,6 @@ var CNSDevToolsServer = class {
201
567
  }
202
568
  async handleTopology(ws, msg) {
203
569
  const { appId, cnsId, appName, version, timestamp, neurons, collaterals, dendrites } = msg;
204
- const now = Date.now();
205
- const app = {
206
- id: appId,
207
- name: appName,
208
- version,
209
- connectedAt: now,
210
- lastSeenAt: now
211
- };
212
- await this.repository.upsertApp(app);
213
- await this.repository.addCnsToApp(appId, cnsId);
214
570
  await this.repository.saveTopology({ appId, cnsId, appName, version, timestamp, neurons, collaterals, dendrites });
215
571
  this.appSockets.set(ws, appId);
216
572
  if (!this.appWsByAppId.has(appId)) this.appWsByAppId.set(appId, /* @__PURE__ */ new Set());
@@ -220,45 +576,31 @@ var CNSDevToolsServer = class {
220
576
  requestId: "__broadcast__",
221
577
  snapshots: await this.repository.getTopology(cnsId)
222
578
  };
223
- this.broadcast({ type: "app.connected", app, topology: { cnsId, neurons, collaterals, dendrites } });
579
+ this.broadcast({ type: "app.connected", app: { id: appId, name: appName, version }, topology: { cnsId, neurons, collaterals, dendrites } });
224
580
  this.broadcastRaw(JSON.stringify(topologyBroadcast));
225
581
  }
226
582
  // ─── UI client query handlers ─────────────────────────────────────────────
227
583
  async handleClientConnect(ws) {
228
584
  this.addClient(ws);
229
- this.send(ws, { type: "apps.result", requestId: "__init__", items: await this.repository.listApps() });
230
585
  this.send(ws, {
231
- type: "topology.result",
232
- requestId: "__init__",
233
- 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
234
591
  });
592
+ const snapshots = await this.repository.getTopology();
593
+ this.send(ws, { type: "apps.result", requestId: "__init__", items: _CNSDevToolsServer.appsOf(snapshots) });
594
+ this.send(ws, { type: "topology.result", requestId: "__init__", snapshots });
235
595
  }
236
596
  async handleAppsQuery(ws, requestId) {
237
- const items = await this.repository.listApps();
238
- this.send(ws, { type: "apps.result", requestId, items });
597
+ this.send(ws, { type: "apps.result", requestId, items: await this.getActiveApps() });
239
598
  }
240
599
  async handleTopologyQuery(ws, requestId, cnsId, appId) {
241
600
  let snapshots = await this.repository.getTopology(cnsId);
242
601
  if (appId) snapshots = snapshots.filter((s) => s.appId === appId);
243
602
  this.send(ws, { type: "topology.result", requestId, snapshots });
244
603
  }
245
- async handleReplayStart(ws, msg) {
246
- const appId = msg.appId ?? (msg.cnsId ? await this.repository.findAppByCns(msg.cnsId) : void 0);
247
- const appSockets = appId ? this.appWsByAppId.get(appId) : void 0;
248
- if (!appSockets || appSockets.size === 0) {
249
- this.send(ws, { type: "replay.rejected", replayId: msg.replayId, reason: "App not connected" });
250
- return;
251
- }
252
- const newStimulationId = `${msg.stimulationId}-replay-${Date.now()}`;
253
- this.send(ws, { type: "replay.accepted", replayId: msg.replayId, newStimulationId });
254
- const payload = JSON.stringify(msg);
255
- for (const appWs of appSockets) {
256
- try {
257
- appWs.send(payload);
258
- } catch {
259
- }
260
- }
261
- }
262
604
  // ─── Resumable actions (retry / clone), Phase 2b-2 ──────────────────────────────
263
605
  //
264
606
  // The UI's request is thin (a stimulationId). The CNS + name→ref registry live in
@@ -268,8 +610,22 @@ var CNSDevToolsServer = class {
268
610
  /** Resolve the app that owns a stimulation's scope, or reject if unreachable. */
269
611
  async resolveScopeApp(scopeName) {
270
612
  const appId = scopeName ? await this.repository.findAppByCns(scopeName) : void 0;
271
- const sockets = appId ? this.appWsByAppId.get(appId) : void 0;
272
- 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;
273
629
  }
274
630
  async handleStimulationRetry(ws, msg) {
275
631
  const reject = (reason) => this.send(ws, {
@@ -282,19 +638,15 @@ var CNSDevToolsServer = class {
282
638
  if (!stim) return reject("unknown stimulation");
283
639
  if (stim.progress.tasks.length === 0)
284
640
  return reject("nothing to resume (empty frontier)");
285
- const appSockets = await this.resolveScopeApp(stim.scopeName);
286
- if (!appSockets) return reject("app not connected");
287
641
  const attempts = await this.stimulationRepository.getAttempts(msg.stimulationId);
288
- const nextAttempt = attempts.length + 1;
289
- const stimulationAttemptId = devtoolsDto.buildAttemptId(msg.stimulationId, nextAttempt);
290
- this.send(ws, {
291
- type: "stimulation.retry.accepted",
292
- requestId: msg.requestId,
293
- stimulationId: msg.stimulationId,
294
- newStimulationAttemptId: stimulationAttemptId
295
- });
296
- const payload = JSON.stringify({
297
- type: "cns.stimulation.resume",
642
+ const latest = attempts.reduce(
643
+ (max, a) => !max || a.attemptNumber > max.attemptNumber ? a : max,
644
+ void 0
645
+ );
646
+ const nextAttempt = (latest?.attemptNumber ?? 0) + 1;
647
+ const stimulationAttemptId = devtoolsDto.buildCNSAttemptId(msg.stimulationId, nextAttempt);
648
+ const handled = await this.carryOut({
649
+ type: "resume",
298
650
  requestId: msg.requestId,
299
651
  scopeName: stim.scopeName,
300
652
  stimulationId: msg.stimulationId,
@@ -302,14 +654,21 @@ var CNSDevToolsServer = class {
302
654
  attemptNumber: nextAttempt,
303
655
  entry: stim.entry,
304
656
  progress: stim.progress,
657
+ // Provenance: which attempt this retry continues from.
658
+ replayOf: latest?.stimulationAttemptId,
305
659
  options: msg.options
306
660
  });
307
- for (const appWs of appSockets) {
308
- try {
309
- appWs.send(payload);
310
- } catch {
311
- }
661
+ if (handled !== true) {
662
+ return reject(
663
+ handled === false ? "no runtime accepted the retry" : `retry failed: ${handled}`
664
+ );
312
665
  }
666
+ this.send(ws, {
667
+ type: "stimulation.retry.accepted",
668
+ requestId: msg.requestId,
669
+ stimulationId: msg.stimulationId,
670
+ newStimulationAttemptId: stimulationAttemptId
671
+ });
313
672
  }
314
673
  async handleStimulationClone(ws, msg) {
315
674
  const reject = (reason) => this.send(ws, {
@@ -320,32 +679,39 @@ var CNSDevToolsServer = class {
320
679
  });
321
680
  const stim = await this.stimulationRepository.getStimulation(msg.stimulationId);
322
681
  if (!stim) return reject("unknown stimulation");
323
- const appSockets = await this.resolveScopeApp(stim.scopeName);
324
- if (!appSockets) return reject("app not connected");
325
- const newStimulationId = devtoolsDto.buildCloneId(msg.stimulationId);
326
- const stimulationAttemptId = devtoolsDto.buildAttemptId(newStimulationId, 1);
327
- this.send(ws, {
328
- type: "stimulation.clone.accepted",
329
- requestId: msg.requestId,
330
- stimulationId: msg.stimulationId,
331
- newStimulationId,
332
- newStimulationAttemptId: stimulationAttemptId
333
- });
334
- const payload = JSON.stringify({
335
- type: "cns.stimulation.launch",
682
+ const newStimulationId = devtoolsDto.buildCNSCloneId(msg.stimulationId);
683
+ const stimulationAttemptId = devtoolsDto.buildCNSAttemptId(newStimulationId, 1);
684
+ const sourceAttempts = await this.stimulationRepository.getAttempts(
685
+ msg.stimulationId
686
+ );
687
+ const replayOf = sourceAttempts.reduce(
688
+ (max, a) => !max || a.attemptNumber > max.attemptNumber ? a : max,
689
+ void 0
690
+ )?.stimulationAttemptId;
691
+ const handled = await this.carryOut({
692
+ type: "launch",
336
693
  requestId: msg.requestId,
337
694
  scopeName: stim.scopeName,
338
695
  stimulationId: newStimulationId,
339
696
  stimulationAttemptId,
340
697
  entry: stim.entry,
698
+ // A clone gets a NEW stimulationId, so this is the ONLY link back to
699
+ // the run it came from.
700
+ replayOf,
341
701
  options: msg.options
342
702
  });
343
- for (const appWs of appSockets) {
344
- try {
345
- appWs.send(payload);
346
- } catch {
347
- }
703
+ if (handled !== true) {
704
+ return reject(
705
+ handled === false ? "no runtime accepted the clone" : `clone failed: ${handled}`
706
+ );
348
707
  }
708
+ this.send(ws, {
709
+ type: "stimulation.clone.accepted",
710
+ requestId: msg.requestId,
711
+ stimulationId: msg.stimulationId,
712
+ newStimulationId,
713
+ newStimulationAttemptId: stimulationAttemptId
714
+ });
349
715
  }
350
716
  // ─── Broadcast helpers ────────────────────────────────────────────────────
351
717
  broadcast(message) {
@@ -394,6 +760,8 @@ var CNSDevToolsServer = class {
394
760
  }
395
761
  };
396
762
 
763
+ exports.CNSDevToolsPollingTransport = CNSDevToolsPollingTransport;
397
764
  exports.CNSDevToolsServer = CNSDevToolsServer;
765
+ exports.startCNSDevTools = startCNSDevTools;
398
766
  //# sourceMappingURL=index.cjs.map
399
767
  //# sourceMappingURL=index.cjs.map