@nanobpm/nano-workforce 0.127.0 → 0.129.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 +14 -0
- package/app/abandon.test.ts +16 -2
- package/app/abandon.ts +39 -17
- 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/app/conformance.test.ts +2 -1
- package/app/conformance.ts +9 -3
- package/app/featureDelivery.test.ts +2 -1
- package/app/instanceTracking.ts +97 -0
- package/app/lineage.test.ts +2 -1
- package/app/lineage.ts +15 -2
- package/app/promotionPoll.test.ts +2 -1
- package/app/retro.test.ts +2 -1
- package/app/retro.ts +9 -2
- package/app/service.test.ts +15 -14
- package/app/service.ts +17 -24
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/e2e/convergence-loop.e2e.ts +41 -8
- package/openapi.yaml +26 -0
- package/operations/acknowledgeEpic.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/getLineage.test.ts +2 -1
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
- package/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
|
@@ -119,7 +119,10 @@
|
|
|
119
119
|
.cockpit-dot[data-liveness="stale"] { background: var(--cockpit-amber); }
|
|
120
120
|
.cockpit-dot[data-liveness="down"] { background: var(--cockpit-red); }
|
|
121
121
|
|
|
122
|
-
.cockpit-worker
|
|
122
|
+
.cockpit-worker,
|
|
123
|
+
.cockpit-worker-drill,
|
|
124
|
+
.cockpit-worker-detail-back,
|
|
125
|
+
.cockpit-worker-current-job {
|
|
123
126
|
background: none;
|
|
124
127
|
border: none;
|
|
125
128
|
color: var(--cockpit-text);
|
|
@@ -130,7 +133,15 @@
|
|
|
130
133
|
text-underline-offset: 2px;
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
.cockpit-worker
|
|
136
|
+
.cockpit-worker-drill {
|
|
137
|
+
color: var(--cockpit-muted);
|
|
138
|
+
font-size: 12px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.cockpit-worker:hover,
|
|
142
|
+
.cockpit-worker-drill:hover,
|
|
143
|
+
.cockpit-worker-detail-back:hover,
|
|
144
|
+
.cockpit-worker-current-job:hover { color: #58a6ff; }
|
|
134
145
|
|
|
135
146
|
.cockpit-supply-process { color: var(--cockpit-muted); }
|
|
136
147
|
|
|
@@ -220,6 +231,58 @@
|
|
|
220
231
|
padding: 8px 0;
|
|
221
232
|
}
|
|
222
233
|
|
|
234
|
+
/* ── Worker detail route (#/cockpit/worker/<instance>): header + current job + filtered history. ── */
|
|
235
|
+
|
|
236
|
+
.cockpit-worker-detail {
|
|
237
|
+
display: grid;
|
|
238
|
+
gap: 12px;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.cockpit-worker-detail-back {
|
|
242
|
+
justify-self: start;
|
|
243
|
+
color: var(--cockpit-muted);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.cockpit-worker-detail-header {
|
|
247
|
+
display: grid;
|
|
248
|
+
gap: 8px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.cockpit-worker-detail-meta {
|
|
252
|
+
display: grid;
|
|
253
|
+
gap: 8px;
|
|
254
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
255
|
+
margin: 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.cockpit-worker-detail-meta-item {
|
|
259
|
+
border: 1px solid rgba(34, 48, 65, 0.7);
|
|
260
|
+
border-radius: 6px;
|
|
261
|
+
padding: 8px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.cockpit-worker-detail-meta-item dt {
|
|
265
|
+
color: var(--cockpit-muted);
|
|
266
|
+
font-size: 11px;
|
|
267
|
+
text-transform: uppercase;
|
|
268
|
+
letter-spacing: 0.04em;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.cockpit-worker-detail-meta-item dd {
|
|
272
|
+
margin: 2px 0 0;
|
|
273
|
+
overflow-wrap: anywhere;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.cockpit-worker-current {
|
|
277
|
+
border-top: 1px solid rgba(34, 48, 65, 0.7);
|
|
278
|
+
padding-top: 10px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.cockpit-worker-current-empty,
|
|
282
|
+
.cockpit-worker-detail-empty {
|
|
283
|
+
color: var(--cockpit-muted);
|
|
284
|
+
}
|
|
285
|
+
|
|
223
286
|
/* Distinguish a live terminal from a replayed (static) past session at a glance. */
|
|
224
287
|
.cockpit-terminal[data-terminal-mode="replay"] {
|
|
225
288
|
border-color: #8957e5;
|
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();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Test-only helper: make a hand-rolled fake `DataLayer.table(name)` also serve the ADR-0065 derived
|
|
2
|
+
// tracking VIEWs (`<table>__tracking`) that the real `@nanobpm/urban` runtime auto-provisions.
|
|
3
|
+
//
|
|
4
|
+
// The app's terminal-edge readers (app/abandon.ts, app/lineage.ts, app/conformance.ts, app/retro.ts,
|
|
5
|
+
// app/service.ts) route through `<table>__tracking` and read `derived_status`. The in-memory fakes in
|
|
6
|
+
// the unit tests only model base tables keyed by name, so a read of `pull_requests__tracking` would
|
|
7
|
+
// hit an empty store. Wrapping a fake's `table` resolver with `withTrackingViews` transparently
|
|
8
|
+
// aliases any tracking VIEW onto its base store and projects the derived column.
|
|
9
|
+
//
|
|
10
|
+
// Fidelity: a fake has no engine instance-state, so it can only compute the VIEW's `ELSE
|
|
11
|
+
// base.<statusField>` fall-through branch — which is exactly how these tests model an already-settled
|
|
12
|
+
// PR (they seed the terminal directly onto the base row). So `derived_status := base.<statusField>`
|
|
13
|
+
// reproduces the real view's pass-through semantics for the states unit tests exercise, with zero
|
|
14
|
+
// duplicated view-naming logic (it defers to the app's SSOT resolvers in app/instanceTracking.ts).
|
|
15
|
+
import { baseStatusFieldFor, baseTableForTrackingView, trackingTargetFor } from "../app/instanceTracking.ts";
|
|
16
|
+
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only fake `table` resolver over dynamic row shapes.
|
|
18
|
+
type TableFn = (name: string, pk?: string) => any;
|
|
19
|
+
|
|
20
|
+
/** Decorate a fake `table(name, pk)` resolver so it also serves every `<table>__tracking` derived
|
|
21
|
+
* VIEW off the corresponding base store, projecting `derived_status := base.<statusField>` onto each
|
|
22
|
+
* row the read methods (`findOne`/`get`/`find`/`all`) return. A non-tracking name is passed through
|
|
23
|
+
* untouched, and the VIEW is read-only (writes are never wrapped). */
|
|
24
|
+
export function withTrackingViews<F extends TableFn>(base: F): F {
|
|
25
|
+
return ((name: string, pk?: string) => {
|
|
26
|
+
const baseName = baseTableForTrackingView(name);
|
|
27
|
+
if (!baseName) return base(name, pk);
|
|
28
|
+
const inner = base(baseName, pk);
|
|
29
|
+
const derivedColumn = trackingTargetFor(baseName).statusColumn;
|
|
30
|
+
const statusField = baseStatusFieldFor(baseName);
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
|
|
32
|
+
const project = (row: any) =>
|
|
33
|
+
row == null ? row : { ...row, [derivedColumn]: row[statusField] };
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
|
|
35
|
+
return new Proxy(inner, {
|
|
36
|
+
get(target: any, prop: string) {
|
|
37
|
+
const value = target[prop];
|
|
38
|
+
if (typeof value !== "function") return value;
|
|
39
|
+
const bound = value.bind(target);
|
|
40
|
+
if (prop === "findOne" || prop === "get") {
|
|
41
|
+
return async (...args: unknown[]) => project(await bound(...args));
|
|
42
|
+
}
|
|
43
|
+
if (prop === "find" || prop === "all") {
|
|
44
|
+
return async (...args: unknown[]) => (await bound(...args)).map(project);
|
|
45
|
+
}
|
|
46
|
+
return bound;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}) as F;
|
|
50
|
+
}
|
package/test/worldDb.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
|
|
7
7
|
import { afterEach } from "node:test";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import type { DataLayer } from "@nanobpm/urban";
|
|
10
|
+
import { withTrackingViews } from "./trackingViews.ts";
|
|
10
11
|
|
|
11
12
|
const openDbs = new Set<DatabaseSync>();
|
|
12
13
|
afterEach(() => {
|
|
@@ -103,7 +104,7 @@ export function memDataFor(migrationFiles: readonly string[]): { data: DataLayer
|
|
|
103
104
|
db.exec(sql);
|
|
104
105
|
}
|
|
105
106
|
const data = {
|
|
106
|
-
table: (name: string, pk = "id") => gateway(db, name, pk),
|
|
107
|
+
table: withTrackingViews((name: string, pk = "id") => gateway(db, name, pk)),
|
|
107
108
|
open: () => openDataSource(db),
|
|
108
109
|
} as unknown as DataLayer;
|
|
109
110
|
return { data, db };
|
|
@@ -3,6 +3,7 @@ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
|
3
3
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
4
|
import { noopLog } from "../../test/log.ts";
|
|
5
5
|
import { memBlackboardSource } from "../../test/blackboardDb.ts";
|
|
6
|
+
import { withTrackingViews } from "../../test/trackingViews.ts";
|
|
6
7
|
import { appendEntry } from "../../app/blackboard.ts";
|
|
7
8
|
import handler from "./worker.ts";
|
|
8
9
|
|
|
@@ -30,7 +31,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
30
31
|
async update() {},
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
34
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
|
|
34
35
|
return { data, stores };
|
|
35
36
|
}
|
|
36
37
|
|