@nanobpm/nano-workforce 0.127.0 → 0.128.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.
Files changed (31) hide show
  1. package/.github/workflows/release.yml +29 -6
  2. package/AGENTS.md +19 -0
  3. package/CHANGELOG.md +7 -0
  4. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  5. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  6. package/app/agentic/cockpit/index.ts +16 -0
  7. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  8. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  9. package/app/agentic/cockpit/supply-boot.ts +76 -10
  10. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  11. package/app/agentic/cockpit/supply-render.ts +14 -2
  12. package/app/agentic/cockpit/transcript-render.ts +6 -2
  13. package/app/agentic/cockpit/transcript-view.ts +9 -0
  14. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  15. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  16. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  17. package/app/agentic/correlation-store.test.ts +99 -0
  18. package/app/agentic/correlation-store.ts +162 -0
  19. package/app/agentic/families/presence.family.test.ts +12 -0
  20. package/app/agentic/families/presence.family.ts +14 -0
  21. package/app/agentic/families/relay.family.test.ts +72 -0
  22. package/app/agentic/families/relay.family.ts +130 -1
  23. package/app/agentic/transcript-read.test.ts +55 -3
  24. package/app/agentic/transcript-read.ts +49 -9
  25. package/db/migrations/078_agentic_correlation.sql +32 -0
  26. package/openapi.yaml +26 -0
  27. package/operations/getAgenticTranscript.ts +3 -2
  28. package/operations/listAgenticTranscripts.ts +2 -1
  29. package/package.json +1 -1
  30. package/pages/cockpit/cockpit.css +65 -2
  31. package/pages/cockpit/mount.js +187 -16
@@ -27,6 +27,21 @@ function isPosInt(value) {
27
27
  return Number.isSafeInteger(value) && value > 0;
28
28
  }
29
29
 
30
+ function parseCockpitRoute(hash) {
31
+ const route = hash.startsWith("#") ? hash.slice(1) : hash;
32
+ if (route === "" || route === "/cockpit" || route === "/cockpit/") return { kind: "main" };
33
+ const prefix = "/cockpit/worker/";
34
+ if (!route.startsWith(prefix)) return { kind: "main" };
35
+ const raw = route.slice(prefix.length);
36
+ if (raw === "") return { kind: "main" };
37
+ try {
38
+ const instance = decodeURIComponent(raw);
39
+ return instance === "" ? { kind: "main" } : { kind: "worker", instance };
40
+ } catch {
41
+ return { kind: "main" };
42
+ }
43
+ }
44
+
30
45
  // ── supply projection (mirrors app/agentic/cockpit/supply-view.ts) ─────────────────────────────
31
46
 
32
47
  function liveness(worker, staleAfterMs) {
@@ -97,7 +112,22 @@ function dot(doc, live) {
97
112
  return node;
98
113
  }
99
114
 
100
- function workerRow(doc, worker, onDrill) {
115
+ function currentJob(worker) {
116
+ const correlation = worker.correlations[0];
117
+ if (correlation != null) return { jobKey: correlation.jobKey, stream: correlation.stream, label: correlation.label };
118
+ const jobKey = worker.jobKeys[0];
119
+ if (jobKey == null) return undefined;
120
+ return { jobKey, stream: worker.stream, label: `job ${jobKey}` };
121
+ }
122
+
123
+ function workerDetailView(view, instance) {
124
+ const worker = view.workers.find((w) => w.instance === instance);
125
+ if (worker == null) return { kind: "missing", instance };
126
+ const job = currentJob(worker);
127
+ return job == null ? { kind: "found", worker } : { kind: "found", worker, currentJob: job };
128
+ }
129
+
130
+ function workerRow(doc, worker, onDrill, onOpenWorker) {
101
131
  const row = el(doc, "tr", "cockpit-supply-worker");
102
132
  row.setAttribute("data-worker", worker.instance);
103
133
  row.setAttribute("data-liveness", worker.liveness);
@@ -107,9 +137,16 @@ function workerRow(doc, worker, onDrill) {
107
137
  nameCell.appendChild(dot(doc, worker.liveness));
108
138
  const button = el(doc, "button", "cockpit-worker", worker.instance);
109
139
  button.setAttribute("type", "button");
140
+ button.setAttribute("data-instance", worker.instance);
110
141
  button.setAttribute("data-stream", worker.stream);
111
- if (onDrill) button.addEventListener("click", () => onDrill(worker.stream));
142
+ if (onOpenWorker) button.addEventListener("click", () => onOpenWorker(worker.instance));
112
143
  nameCell.appendChild(button);
144
+ const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
145
+ drill.setAttribute("type", "button");
146
+ drill.setAttribute("data-instance", worker.instance);
147
+ drill.setAttribute("data-stream", worker.stream);
148
+ if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
149
+ nameCell.appendChild(drill);
113
150
  row.appendChild(nameCell);
114
151
 
115
152
  row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
@@ -138,7 +175,7 @@ function workerRow(doc, worker, onDrill) {
138
175
  return row;
139
176
  }
140
177
 
141
- function leafSection(doc, leaf, onDrill) {
178
+ function leafSection(doc, leaf, onDrill, onOpenWorker) {
142
179
  const section = el(doc, "section", "cockpit-leaf");
143
180
  section.setAttribute("data-leaf", leaf.token);
144
181
  const header = el(doc, "div", "cockpit-leaf-head");
@@ -152,13 +189,13 @@ function leafSection(doc, leaf, onDrill) {
152
189
  thead.appendChild(head);
153
190
  table.appendChild(thead);
154
191
  const tbody = el(doc, "tbody", "cockpit-supply-tbody");
155
- for (const worker of leaf.workers) tbody.appendChild(workerRow(doc, worker, onDrill));
192
+ for (const worker of leaf.workers) tbody.appendChild(workerRow(doc, worker, onDrill, onOpenWorker));
156
193
  table.appendChild(tbody);
157
194
  section.appendChild(table);
158
195
  return section;
159
196
  }
160
197
 
161
- function renderSupply(host, doc, view, onDrill) {
198
+ function renderSupply(host, doc, view, onDrill, onOpenWorker) {
162
199
  host.replaceChildren();
163
200
  const root = el(doc, "div", "cockpit-supply");
164
201
  root.setAttribute("data-worker-count", String(view.count));
@@ -177,11 +214,68 @@ function renderSupply(host, doc, view, onDrill) {
177
214
  return;
178
215
  }
179
216
  const list = el(doc, "div", "cockpit-supply-list");
180
- for (const leaf of view.leaves) list.appendChild(leafSection(doc, leaf, onDrill));
217
+ for (const leaf of view.leaves) list.appendChild(leafSection(doc, leaf, onDrill, onOpenWorker));
181
218
  root.appendChild(list);
182
219
  host.appendChild(root);
183
220
  }
184
221
 
222
+ function backButton(doc, onBack) {
223
+ const button = el(doc, "button", "cockpit-worker-detail-back", "\u2190 Workers");
224
+ button.setAttribute("type", "button");
225
+ if (onBack) button.addEventListener("click", onBack);
226
+ return button;
227
+ }
228
+
229
+ function renderWorkerDetail(host, doc, view, onBack, onDrill) {
230
+ host.replaceChildren();
231
+ const root = el(doc, "section", "cockpit-worker-detail");
232
+ root.appendChild(backButton(doc, onBack));
233
+ if (view.kind === "missing") {
234
+ root.setAttribute("data-worker-missing", view.instance);
235
+ root.appendChild(el(doc, "h1", "cockpit-title", `Worker ${view.instance}`));
236
+ root.appendChild(el(doc, "div", "cockpit-worker-detail-empty", `Worker ${view.instance} is not in the current supply report.`));
237
+ host.appendChild(root);
238
+ return;
239
+ }
240
+ const worker = view.worker;
241
+ root.setAttribute("data-worker-detail", worker.instance);
242
+ root.setAttribute("data-liveness", worker.liveness);
243
+ const header = el(doc, "header", "cockpit-worker-detail-header");
244
+ header.appendChild(el(doc, "h1", "cockpit-title", worker.instance));
245
+ const meta = el(doc, "dl", "cockpit-worker-detail-meta");
246
+ for (const [klass, label, value] of [
247
+ ["identity", "identity", worker.identity],
248
+ ["host", "host", worker.host],
249
+ ["family", "family", worker.family],
250
+ ["liveness", "liveness", worker.liveness],
251
+ ]) {
252
+ const item = el(doc, "div", "cockpit-worker-detail-meta-item");
253
+ item.setAttribute("data-field", klass);
254
+ item.appendChild(el(doc, "dt", undefined, label));
255
+ const dd = el(doc, "dd", `cockpit-worker-detail-${klass}`, value);
256
+ if (klass === "liveness") dd.setAttribute("data-liveness", worker.liveness);
257
+ item.appendChild(dd);
258
+ meta.appendChild(item);
259
+ }
260
+ header.appendChild(meta);
261
+ root.appendChild(header);
262
+ const current = el(doc, "section", "cockpit-worker-current");
263
+ current.appendChild(el(doc, "h2", "cockpit-panel-title", "Current job"));
264
+ if (view.currentJob == null) {
265
+ current.appendChild(el(doc, "div", "cockpit-worker-current-empty", "No active job."));
266
+ } else {
267
+ const job = view.currentJob;
268
+ const button = el(doc, "button", "cockpit-worker-current-job", job.label);
269
+ button.setAttribute("type", "button");
270
+ button.setAttribute("data-job-key", job.jobKey);
271
+ button.setAttribute("data-stream", job.stream);
272
+ if (onDrill) button.addEventListener("click", () => onDrill(job.stream));
273
+ current.appendChild(button);
274
+ }
275
+ root.appendChild(current);
276
+ host.appendChild(root);
277
+ }
278
+
185
279
  // ── past-sessions projection + render (mirrors app/agentic/cockpit/transcript-view.ts + -render.ts) ──
186
280
 
187
281
  function humanBytes(bytes) {
@@ -251,18 +345,18 @@ function sessionRow(doc, session, onReplay, activeStream) {
251
345
  return row;
252
346
  }
253
347
 
254
- function renderTranscripts(host, doc, view, onReplay, activeStream) {
348
+ function renderTranscripts(host, doc, view, onReplay, activeStream, title = "Past sessions", emptyText = "No captured sessions yet.") {
255
349
  host.replaceChildren();
256
350
  const root = el(doc, "div", "cockpit-past");
257
351
  root.setAttribute("data-session-count", String(view.count));
258
352
  const header = el(doc, "header", "cockpit-past-header");
259
- header.appendChild(el(doc, "h2", "cockpit-past-title", "Past sessions"));
353
+ header.appendChild(el(doc, "h2", "cockpit-past-title", title));
260
354
  const summary = el(doc, "span", "cockpit-past-summary", view.retention != null ? `${view.count} \u00b7 kept ${view.retention}` : `${view.count}`);
261
355
  summary.setAttribute("data-summary", "past");
262
356
  header.appendChild(summary);
263
357
  root.appendChild(header);
264
358
  if (view.count === 0) {
265
- const empty = el(doc, "div", "cockpit-past-empty", "No captured sessions yet.");
359
+ const empty = el(doc, "div", "cockpit-past-empty", emptyText);
266
360
  empty.setAttribute("data-empty", "true");
267
361
  root.appendChild(empty);
268
362
  host.appendChild(root);
@@ -413,12 +507,15 @@ export function mountCockpit(host, opts = {}) {
413
507
  let terminal; // the current xterm sink
414
508
  let mode; // "live" | "replay" | undefined
415
509
  let shownStream;
510
+ let route = parseCockpitRoute(location.hash);
511
+ let view;
416
512
  // Bumped by every drillInto()/replayInto()/dispose() that claims the terminal region, so a slow
417
513
  // replay fetch that resolves after a newer selection drops its result instead of clobbering it.
418
514
  let opToken = 0;
419
515
  // True while a refreshPast() fetch is outstanding, so the supply poll never stacks past-fetches
420
516
  // against a slow/hung transcripts endpoint.
421
517
  let pastRefreshing = false;
518
+ let pastRefreshPending = false;
422
519
 
423
520
  function setMode(next, stream) {
424
521
  mode = next;
@@ -436,6 +533,52 @@ export function mountCockpit(host, opts = {}) {
436
533
  terminal = undefined;
437
534
  }
438
535
 
536
+ function routeInstance() {
537
+ return route.kind === "worker" ? route.instance : undefined;
538
+ }
539
+
540
+ function renderRoute() {
541
+ if (view == null) return;
542
+ if (route.kind === "worker") {
543
+ renderWorkerDetail(listRegion, doc, workerDetailView(view, route.instance), backToMain, drillInto);
544
+ return;
545
+ }
546
+ renderSupply(listRegion, doc, view, drillInto, openWorker);
547
+ }
548
+
549
+ function openWorker(instance) {
550
+ const hash = `#/cockpit/worker/${encodeURIComponent(instance)}`;
551
+ if (location.hash === hash) {
552
+ route = { kind: "worker", instance };
553
+ renderRoute();
554
+ void refreshPast(instance);
555
+ return;
556
+ }
557
+ location.hash = hash;
558
+ }
559
+
560
+ function backToMain() {
561
+ if (location.hash === "#/cockpit") {
562
+ route = { kind: "main" };
563
+ renderRoute();
564
+ void refreshPast();
565
+ return;
566
+ }
567
+ location.hash = "#/cockpit";
568
+ }
569
+
570
+ function onHashChange() {
571
+ route = parseCockpitRoute(location.hash);
572
+ try {
573
+ renderRoute();
574
+ } catch (err) {
575
+ onError(err);
576
+ }
577
+ void refreshPast(routeInstance());
578
+ }
579
+
580
+ window.addEventListener("hashchange", onHashChange);
581
+
439
582
  function drillInto(stream) {
440
583
  if (disposed || (mode === "live" && drill?.stream === stream)) return;
441
584
  // Claim the terminal region: bump the op token so an in-flight replay drops its stale result.
@@ -503,16 +646,26 @@ export function mountCockpit(host, opts = {}) {
503
646
  const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from ?? 0 });
504
647
  replayTranscript(session, data);
505
648
  setMode("replay", stream);
506
- void refreshPast();
649
+ void refreshPast(routeInstance());
507
650
  } catch (err) {
508
651
  onError(err);
509
652
  }
510
653
  }
511
654
 
512
- async function refreshPast() {
655
+ function transcriptsListUrl(instance) {
656
+ if (instance == null) return transcriptsUrl;
657
+ const url = new URL(transcriptsUrl, location.href);
658
+ url.searchParams.set("instance", instance);
659
+ return url.href;
660
+ }
661
+
662
+ async function refreshPast(instance = routeInstance()) {
513
663
  // Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
514
664
  // so the supply poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.
515
- if (pastRefreshing) return;
665
+ if (pastRefreshing) {
666
+ pastRefreshPending = true;
667
+ return;
668
+ }
516
669
  pastRefreshing = true;
517
670
  try {
518
671
  let report;
@@ -525,7 +678,7 @@ export function mountCockpit(host, opts = {}) {
525
678
  abortTimer.unref?.();
526
679
  let res;
527
680
  try {
528
- res = await fetch(transcriptsUrl, { headers: jsonHeaders(), signal: controller.signal });
681
+ res = await fetch(transcriptsListUrl(instance), { headers: jsonHeaders(), signal: controller.signal });
529
682
  } finally {
530
683
  clearTimeout(abortTimer);
531
684
  }
@@ -536,13 +689,29 @@ export function mountCockpit(host, opts = {}) {
536
689
  return;
537
690
  }
538
691
  if (disposed) return;
692
+ if (routeInstance() !== instance) {
693
+ pastRefreshPending = true;
694
+ return;
695
+ }
539
696
  try {
540
- renderTranscripts(pastRegion, doc, transcriptsView(report), replayInto, mode === "replay" ? shownStream : undefined);
697
+ renderTranscripts(
698
+ pastRegion,
699
+ doc,
700
+ transcriptsView(report),
701
+ replayInto,
702
+ mode === "replay" ? shownStream : undefined,
703
+ instance == null ? "Past sessions" : "Job history",
704
+ instance == null ? "No captured sessions yet." : "No captured sessions for this worker yet.",
705
+ );
541
706
  } catch (err) {
542
707
  onError(err);
543
708
  }
544
709
  } finally {
545
710
  pastRefreshing = false;
711
+ if (pastRefreshPending && !disposed) {
712
+ pastRefreshPending = false;
713
+ void refreshPast(routeInstance());
714
+ }
546
715
  }
547
716
  }
548
717
 
@@ -559,12 +728,13 @@ export function mountCockpit(host, opts = {}) {
559
728
  }
560
729
  if (disposed) return;
561
730
  try {
562
- renderSupply(listRegion, doc, supplyView(report, staleAfterMs), drillInto);
731
+ view = supplyView(report, staleAfterMs);
732
+ renderRoute();
563
733
  } catch (err) {
564
734
  onError(err);
565
735
  }
566
736
  // Fire-and-forget: a hung transcripts endpoint must never stall the supply poll's next tick.
567
- void refreshPast();
737
+ void refreshPast(routeInstance());
568
738
  }
569
739
 
570
740
  function tick(gen) {
@@ -594,6 +764,7 @@ export function mountCockpit(host, opts = {}) {
594
764
  stop();
595
765
  teardownTerminal();
596
766
  setMode(undefined, undefined);
767
+ window.removeEventListener("hashchange", onHashChange);
597
768
  }
598
769
 
599
770
  start();