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