@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.
- package/.github/workflows/release.yml +29 -6
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +7 -0
- package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
- package/app/agentic/cockpit/cockpit-route.ts +17 -0
- package/app/agentic/cockpit/index.ts +16 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
- package/app/agentic/cockpit/supply-boot.test.ts +2 -2
- package/app/agentic/cockpit/supply-boot.ts +76 -10
- package/app/agentic/cockpit/supply-render.test.ts +13 -4
- package/app/agentic/cockpit/supply-render.ts +14 -2
- package/app/agentic/cockpit/transcript-render.ts +6 -2
- package/app/agentic/cockpit/transcript-view.ts +9 -0
- package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
- package/app/agentic/cockpit/worker-detail-render.ts +88 -0
- package/app/agentic/cockpit/worker-detail-view.ts +43 -0
- package/app/agentic/correlation-store.test.ts +99 -0
- package/app/agentic/correlation-store.ts +162 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +14 -0
- package/app/agentic/families/relay.family.test.ts +72 -0
- package/app/agentic/families/relay.family.ts +130 -1
- package/app/agentic/transcript-read.test.ts +55 -3
- package/app/agentic/transcript-read.ts +49 -9
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/openapi.yaml +26 -0
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
package/pages/cockpit/mount.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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();
|