@nanobpm/nano-workforce 0.54.0 → 0.56.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/CHANGELOG.md +14 -0
- package/app/agentic/cockpit/index.ts +30 -0
- package/app/agentic/cockpit/supply-boot.test.ts +279 -0
- package/app/agentic/cockpit/supply-boot.ts +282 -0
- package/app/agentic/cockpit/supply-render.test.ts +112 -0
- package/app/agentic/cockpit/supply-render.ts +165 -0
- package/app/agentic/cockpit/supply-view.test.ts +116 -0
- package/app/agentic/cockpit/supply-view.ts +243 -0
- package/app/agentic/correlation.test.ts +132 -0
- package/app/agentic/correlation.ts +193 -0
- package/app/agentic/families/correlation.family.test.ts +47 -0
- package/app/agentic/families/correlation.family.ts +39 -0
- package/docs/agentic-cockpit.md +135 -0
- package/openapi.yaml +126 -0
- package/operations/getAgenticSupply.test.ts +193 -0
- package/operations/getAgenticSupply.ts +82 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +162 -0
- package/pages/cockpit/embed.html +42 -0
- package/pages/cockpit/mount.js +362 -0
- package/pages/cockpit/standalone.html +46 -0
- package/pages/cockpit.page.json +46 -0
- package/pages/epic-detail.page.json +2 -1
- package/pages/epic.page.json +2 -1
- package/pages/home.page.json +4 -0
- package/test/agentic-cockpit-doubles.ts +136 -0
- package/test/agentic-e2e.test.ts +258 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.56.0](https://github.com/nanobpm/nano-workforce/compare/v0.55.0...v0.56.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* correlate jobKeys to process/plan across supply report + cockpit (H6) ([#182](https://github.com/nanobpm/nano-workforce/issues/182)) ([f6e2b0d](https://github.com/nanobpm/nano-workforce/commit/f6e2b0d5f804c4c30863e5f5c66bbdc835025046)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#149](https://github.com/nanobpm/nano-workforce/issues/149)
|
|
7
|
+
|
|
8
|
+
# [0.55.0](https://github.com/nanobpm/nano-workforce/compare/v0.54.0...v0.55.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* supply-only agentic cockpit page + report endpoint (H5) ([#173](https://github.com/nanobpm/nano-workforce/issues/173)) ([428f7f3](https://github.com/nanobpm/nano-workforce/commit/428f7f3a8ca35ea05c8b9c0dbc478d24d125486a)), closes [#144](https://github.com/nanobpm/nano-workforce/issues/144) [#146](https://github.com/nanobpm/nano-workforce/issues/146) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#148](https://github.com/nanobpm/nano-workforce/issues/148) [#144](https://github.com/nanobpm/nano-workforce/issues/144) [#146](https://github.com/nanobpm/nano-workforce/issues/146)
|
|
14
|
+
|
|
1
15
|
# [0.54.0](https://github.com/nanobpm/nano-workforce/compare/v0.53.0...v0.54.0) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The SUPPLY-only agentic cockpit (ADR 0056, H5 / #148) — the app-side core the Urban page + browser
|
|
2
|
+
// shells boot. It renders the live worker/supply list (family, host, current jobs, liveness) from the
|
|
3
|
+
// H1 registry snapshot and streams a drilled-in worker's terminal over the H3 relay, reusing the
|
|
4
|
+
// packaged `@nanobpm/agentic/cockpit` relay client + resume-from-offset terminal session.
|
|
5
|
+
//
|
|
6
|
+
// The DEMAND×supply matrix, missing-agent-type reds, and diversity-SLO lights are OUT OF SCOPE for
|
|
7
|
+
// this epic (#142) and deferred to the paired enrolment epic #152.
|
|
8
|
+
export {
|
|
9
|
+
bootSupplyCockpit,
|
|
10
|
+
type CreateTerminal,
|
|
11
|
+
type SupplyCockpitEnv,
|
|
12
|
+
type SupplyCockpitHandle,
|
|
13
|
+
type TimerHandle,
|
|
14
|
+
} from "./supply-boot.ts";
|
|
15
|
+
export {
|
|
16
|
+
type RenderSupplyOptions,
|
|
17
|
+
renderSupply,
|
|
18
|
+
type SupplyDom,
|
|
19
|
+
} from "./supply-render.ts";
|
|
20
|
+
export {
|
|
21
|
+
type Liveness,
|
|
22
|
+
type SupplyLeafReport,
|
|
23
|
+
type SupplyLeafView,
|
|
24
|
+
type SupplyReport,
|
|
25
|
+
type SupplyView,
|
|
26
|
+
type SupplyViewOptions,
|
|
27
|
+
type SupplyWorkerReport,
|
|
28
|
+
type SupplyWorkerView,
|
|
29
|
+
supplyView,
|
|
30
|
+
} from "./supply-view.ts";
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Unit tests for the SUPPLY cockpit boot layer (H5 / #148), on the injected fake DOM + fake sockets.
|
|
2
|
+
//
|
|
3
|
+
// These are the acceptance's testable heart: the cockpit renders the live worker/supply list; drilling
|
|
4
|
+
// a worker streams its terminal; the terminal survives a poll refresh AND a reconnect (resume-from-
|
|
5
|
+
// offset, no loss/dup); the poll is self-scheduling. No real browser, no real socket, single run.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
|
|
9
|
+
import { FakeDocument, FakeElement, FakeSocket } from "../../../test/agentic-cockpit-doubles.ts";
|
|
10
|
+
import { bootSupplyCockpit, type SupplyCockpitEnv } from "./supply-boot.ts";
|
|
11
|
+
import type { SupplyReport } from "./supply-view.ts";
|
|
12
|
+
|
|
13
|
+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
14
|
+
|
|
15
|
+
const served: SupplyReport = {
|
|
16
|
+
count: 1,
|
|
17
|
+
workers: [{ instance: "wk-a", identity: "leaf-1", stream: "wk-a", family: "senior", host: "h1", jobKeys: [], live: true, staleMs: 0 }],
|
|
18
|
+
leaves: [
|
|
19
|
+
{
|
|
20
|
+
token: "leaf-1",
|
|
21
|
+
workers: [{ instance: "wk-a", identity: "leaf-1", stream: "wk-a", family: "senior", host: "h1", jobKeys: [], live: true, staleMs: 0 }],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface Rig {
|
|
27
|
+
readonly env: SupplyCockpitEnv;
|
|
28
|
+
readonly host: FakeElement;
|
|
29
|
+
readonly sockets: FakeSocket[];
|
|
30
|
+
readonly terminalWrites: string[];
|
|
31
|
+
terminalMounts: number;
|
|
32
|
+
terminalDisposes: number;
|
|
33
|
+
readonly timers: Array<{ run: () => void; ms: number }>;
|
|
34
|
+
reconnect: (() => void) | undefined;
|
|
35
|
+
report: SupplyReport;
|
|
36
|
+
errors: unknown[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rig(): Rig {
|
|
40
|
+
const host = new FakeElement("body");
|
|
41
|
+
const sockets: FakeSocket[] = [];
|
|
42
|
+
const terminalWrites: string[] = [];
|
|
43
|
+
const timers: Array<{ run: () => void; ms: number }> = [];
|
|
44
|
+
const state: Rig = {
|
|
45
|
+
host,
|
|
46
|
+
sockets,
|
|
47
|
+
terminalWrites,
|
|
48
|
+
terminalMounts: 0,
|
|
49
|
+
terminalDisposes: 0,
|
|
50
|
+
timers,
|
|
51
|
+
reconnect: undefined,
|
|
52
|
+
report: served,
|
|
53
|
+
errors: [],
|
|
54
|
+
env: {
|
|
55
|
+
host,
|
|
56
|
+
doc: new FakeDocument(),
|
|
57
|
+
fetchSupply: () => Promise.resolve(state.report),
|
|
58
|
+
connectRelay: () => {
|
|
59
|
+
const s = new FakeSocket();
|
|
60
|
+
sockets.push(s);
|
|
61
|
+
return s;
|
|
62
|
+
},
|
|
63
|
+
createTerminal: (terminalHost) => {
|
|
64
|
+
state.terminalMounts += 1;
|
|
65
|
+
terminalHost.appendChild(new FakeElement("pre"));
|
|
66
|
+
return {
|
|
67
|
+
write: (chunk) => terminalWrites.push(chunk),
|
|
68
|
+
dispose: () => {
|
|
69
|
+
state.terminalDisposes += 1;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
schedule: (run) => {
|
|
74
|
+
state.reconnect = run;
|
|
75
|
+
},
|
|
76
|
+
setTimer: (run, ms) => {
|
|
77
|
+
timers.push({ run, ms });
|
|
78
|
+
return timers.length - 1;
|
|
79
|
+
},
|
|
80
|
+
clearTimer: () => {},
|
|
81
|
+
onError: (err) => state.errors.push(err),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("refresh renders the live worker/supply list into the host", async () => {
|
|
88
|
+
const r = rig();
|
|
89
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
90
|
+
await cockpit.refresh();
|
|
91
|
+
assert.equal(r.host.byData("leaf", "leaf-1").length, 1);
|
|
92
|
+
assert.equal(r.host.byData("worker", "wk-a").length, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a later poll reflects a changed report (a worker going down)", async () => {
|
|
96
|
+
const r = rig();
|
|
97
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
98
|
+
await cockpit.refresh();
|
|
99
|
+
assert.equal(r.host.byData("worker", "wk-a")[0]?.getAttribute("data-liveness"), "live");
|
|
100
|
+
|
|
101
|
+
r.report = {
|
|
102
|
+
count: 1,
|
|
103
|
+
workers: [{ instance: "wk-a", identity: "leaf-1", stream: "wk-a", family: "senior", host: "h1", jobKeys: [], live: false, staleMs: 0 }],
|
|
104
|
+
leaves: [
|
|
105
|
+
{
|
|
106
|
+
token: "leaf-1",
|
|
107
|
+
workers: [{ instance: "wk-a", identity: "leaf-1", stream: "wk-a", family: "senior", host: "h1", jobKeys: [], live: false, staleMs: 0 }],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
await cockpit.refresh();
|
|
112
|
+
assert.equal(r.host.byData("worker", "wk-a")[0]?.getAttribute("data-liveness"), "down");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("drilling into a worker subscribes its relay stream on connect", async () => {
|
|
116
|
+
const r = rig();
|
|
117
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
118
|
+
await cockpit.refresh();
|
|
119
|
+
cockpit.drill("wk-a");
|
|
120
|
+
assert.equal(cockpit.currentStream, "wk-a");
|
|
121
|
+
assert.equal(r.sockets.length, 1);
|
|
122
|
+
|
|
123
|
+
r.sockets[0]?.fireOpen();
|
|
124
|
+
const subs = r.sockets[0]?.subscribeFrames() ?? [];
|
|
125
|
+
assert.equal(subs.length, 1);
|
|
126
|
+
assert.deepEqual(subs[0]?.payload, { op: "subscribe", stream: "wk-a", from: 0, credit: 1024 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("clicking a rendered worker button drills its stream", async () => {
|
|
130
|
+
const r = rig();
|
|
131
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
132
|
+
await cockpit.refresh();
|
|
133
|
+
const button = r.host.byClass("cockpit-worker").find((b) => b.getAttribute("data-stream") === "wk-a");
|
|
134
|
+
button?.dispatch("click");
|
|
135
|
+
assert.equal(cockpit.currentStream, "wk-a");
|
|
136
|
+
assert.equal(r.sockets.length, 1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("relay output is written to the drilled worker's terminal", async () => {
|
|
140
|
+
const r = rig();
|
|
141
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
142
|
+
await cockpit.refresh();
|
|
143
|
+
cockpit.drill("wk-a");
|
|
144
|
+
r.sockets[0]?.fireOpen();
|
|
145
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 0, chunk: "boot\n" } });
|
|
146
|
+
assert.deepEqual(r.terminalWrites, ["boot\n"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("the terminal survives a list refresh — it is not re-mounted and keeps streaming", async () => {
|
|
150
|
+
const r = rig();
|
|
151
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
152
|
+
await cockpit.refresh();
|
|
153
|
+
cockpit.drill("wk-a");
|
|
154
|
+
r.sockets[0]?.fireOpen();
|
|
155
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 0, chunk: "a" } });
|
|
156
|
+
|
|
157
|
+
await cockpit.refresh(); // a poll re-renders the worker list
|
|
158
|
+
assert.equal(r.terminalMounts, 1, "the terminal was not re-mounted by the refresh");
|
|
159
|
+
|
|
160
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "wk-a", offset: 1, chunk: "b" } });
|
|
161
|
+
assert.deepEqual(r.terminalWrites, ["a", "b"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the terminal survives a cockpit reconnect — resume-from-offset, no loss, no dup", async () => {
|
|
165
|
+
const r = rig();
|
|
166
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
167
|
+
await cockpit.refresh();
|
|
168
|
+
cockpit.drill("wk-a");
|
|
169
|
+
|
|
170
|
+
const s1 = r.sockets[0];
|
|
171
|
+
s1?.fireOpen();
|
|
172
|
+
s1?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 0, chunk: "a" } });
|
|
173
|
+
s1?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "wk-a", offset: 1, chunk: "b" } });
|
|
174
|
+
|
|
175
|
+
// The cockpit's socket drops; the client schedules a reconnect.
|
|
176
|
+
s1?.fireClose();
|
|
177
|
+
assert.ok(r.reconnect !== undefined, "a reconnect was scheduled");
|
|
178
|
+
r.reconnect?.();
|
|
179
|
+
assert.equal(r.sockets.length, 2, "a fresh socket was opened");
|
|
180
|
+
|
|
181
|
+
const s2 = r.sockets[1];
|
|
182
|
+
s2?.fireOpen(); // re-attach → resume from offset 2
|
|
183
|
+
const subs = s2?.subscribeFrames() ?? [];
|
|
184
|
+
assert.deepEqual(subs.at(-1)?.payload, { op: "subscribe", stream: "wk-a", from: 2, credit: 1024 });
|
|
185
|
+
|
|
186
|
+
// The hub replays the retained tail (re-sends 1) then continues.
|
|
187
|
+
s2?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 1, chunk: "b" } });
|
|
188
|
+
s2?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "wk-a", offset: 2, chunk: "c" } });
|
|
189
|
+
assert.deepEqual(r.terminalWrites, ["a", "b", "c"], "no lost and no duplicated output across reconnect");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("drilling the same stream twice does not re-open; a different stream switches + disposes the prior", async () => {
|
|
193
|
+
const r = rig();
|
|
194
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
195
|
+
await cockpit.refresh();
|
|
196
|
+
cockpit.drill("wk-a");
|
|
197
|
+
cockpit.drill("wk-a");
|
|
198
|
+
assert.equal(r.sockets.length, 1);
|
|
199
|
+
assert.equal(r.terminalMounts, 1);
|
|
200
|
+
assert.equal(r.terminalDisposes, 0);
|
|
201
|
+
|
|
202
|
+
cockpit.drill("wk-b");
|
|
203
|
+
assert.equal(r.sockets[0]?.closed, true, "the previous connection was closed");
|
|
204
|
+
assert.equal(r.sockets.length, 2);
|
|
205
|
+
assert.equal(r.terminalMounts, 2);
|
|
206
|
+
assert.equal(r.terminalDisposes, 1, "prior terminal disposed on stream switch");
|
|
207
|
+
assert.equal(cockpit.currentStream, "wk-b");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("start runs a pass and self-schedules the next; stop halts it", async () => {
|
|
211
|
+
const r = rig();
|
|
212
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
213
|
+
cockpit.start();
|
|
214
|
+
await flush();
|
|
215
|
+
assert.equal(r.host.byData("worker", "wk-a").length, 1, "first pass rendered");
|
|
216
|
+
assert.equal(r.timers.length, 1, "next pass scheduled once");
|
|
217
|
+
|
|
218
|
+
r.timers[0]?.run();
|
|
219
|
+
await flush();
|
|
220
|
+
assert.equal(r.timers.length, 2, "a subsequent pass was scheduled");
|
|
221
|
+
|
|
222
|
+
cockpit.stop();
|
|
223
|
+
const before = r.timers.length;
|
|
224
|
+
await flush();
|
|
225
|
+
assert.equal(r.timers.length, before, "no further passes after stop");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a stop→start race does not leave two overlapping poll chains scheduling", async () => {
|
|
229
|
+
const r = rig();
|
|
230
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
231
|
+
cockpit.start();
|
|
232
|
+
cockpit.stop();
|
|
233
|
+
cockpit.start();
|
|
234
|
+
await flush();
|
|
235
|
+
assert.equal(r.timers.length, 1, "exactly one poll chain scheduled after the race");
|
|
236
|
+
cockpit.stop();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a fetch error is reported and does not wedge the poll", async () => {
|
|
240
|
+
const r = rig();
|
|
241
|
+
const failing: SupplyCockpitEnv = { ...r.env, fetchSupply: () => Promise.reject(new Error("boom")) };
|
|
242
|
+
const cockpit = bootSupplyCockpit(failing);
|
|
243
|
+
await cockpit.refresh();
|
|
244
|
+
assert.equal(r.errors.length, 1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("dispose stops polling and closes the terminal connection", async () => {
|
|
248
|
+
const r = rig();
|
|
249
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
250
|
+
await cockpit.refresh();
|
|
251
|
+
cockpit.drill("wk-a");
|
|
252
|
+
cockpit.dispose();
|
|
253
|
+
assert.equal(r.sockets[0]?.closed, true);
|
|
254
|
+
assert.equal(r.terminalDisposes, 1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("an invalid refreshMs fails fast at construction", () => {
|
|
258
|
+
const base = rig().env;
|
|
259
|
+
for (const refreshMs of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN]) {
|
|
260
|
+
assert.throws(
|
|
261
|
+
() => bootSupplyCockpit({ ...base, refreshMs }),
|
|
262
|
+
/refreshMs must be a positive safe integer/,
|
|
263
|
+
`refreshMs ${refreshMs} should be rejected`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("injecting setTimer without clearTimer (or vice versa) fails fast", () => {
|
|
269
|
+
const base = rig().env;
|
|
270
|
+
const noop = () => 0;
|
|
271
|
+
assert.throws(
|
|
272
|
+
() => bootSupplyCockpit({ ...base, setTimer: noop, clearTimer: undefined }),
|
|
273
|
+
/setTimer and clearTimer must be provided together/,
|
|
274
|
+
);
|
|
275
|
+
assert.throws(
|
|
276
|
+
() => bootSupplyCockpit({ ...base, setTimer: undefined, clearTimer: () => {} }),
|
|
277
|
+
/setTimer and clearTimer must be provided together/,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// The SUPPLY-only cockpit boot/orchestration layer (ADR 0056, H5 / #148).
|
|
2
|
+
//
|
|
3
|
+
// Wires the supply cockpit together from injected capabilities, with NO direct dependency on the
|
|
4
|
+
// browser, a socket implementation, or xterm.js — everything is passed in ({@link SupplyCockpitEnv}).
|
|
5
|
+
// That is what lets the same page render identically embedded (console App View, ADR 0057) and
|
|
6
|
+
// standalone (the shells differ only in the `host` element and the concrete capabilities), and what
|
|
7
|
+
// makes the live-refresh + drill-in + resume-on-reconnect path unit-testable on Node.
|
|
8
|
+
//
|
|
9
|
+
// It mirrors the packaged `@nanobpm/agentic/cockpit` boot DISCIPLINE (a self-scheduling poll; a
|
|
10
|
+
// persistent terminal region a refresh never wipes; drill-in that resumes-from-offset on every
|
|
11
|
+
// reconnect) but renders the SUPPLY-only worker list ({@link ./supply-render.ts}) instead of the
|
|
12
|
+
// demand×supply matrix. The genuinely reusable, correctness-critical parts — the relay client and the
|
|
13
|
+
// resume-from-offset terminal session — are REUSED from the package ({@link RelayChannelClient},
|
|
14
|
+
// {@link TerminalSession}); only the supply projection the package does not provide is authored here.
|
|
15
|
+
//
|
|
16
|
+
// Responsibilities:
|
|
17
|
+
// - a self-scheduling poll of the app's SUPPLY report that re-renders the worker list each pass (a
|
|
18
|
+
// slow fetch can't overlap the next — mirrors the nano-workforce review-poller discipline);
|
|
19
|
+
// - drill-into-a-worker: open a {@link RelayChannelClient} + {@link TerminalSession} for the selected
|
|
20
|
+
// stream, mount its output into a PERSISTENT terminal region (so a list refresh never wipes it),
|
|
21
|
+
// and re-attach on every reconnect so the terminal SURVIVES a cockpit reconnect via resume-from-offset.
|
|
22
|
+
import {
|
|
23
|
+
type DocumentLike,
|
|
24
|
+
type ElementLike,
|
|
25
|
+
RelayChannelClient,
|
|
26
|
+
type Scheduler,
|
|
27
|
+
type SocketFactory,
|
|
28
|
+
TerminalSession,
|
|
29
|
+
type TerminalSink,
|
|
30
|
+
} from "@nanobpm/agentic/cockpit";
|
|
31
|
+
import { renderSupply } from "./supply-render.ts";
|
|
32
|
+
import type { SupplyReport } from "./supply-view.ts";
|
|
33
|
+
import { supplyView } from "./supply-view.ts";
|
|
34
|
+
|
|
35
|
+
/** Mounts a terminal into `host` and returns the sink relay output is written to. */
|
|
36
|
+
export type CreateTerminal = (host: ElementLike) => TerminalSink;
|
|
37
|
+
|
|
38
|
+
/** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
|
|
39
|
+
export type TimerHandle = unknown;
|
|
40
|
+
|
|
41
|
+
export interface SupplyCockpitEnv {
|
|
42
|
+
/** The element the cockpit renders into (standalone: `document.body`; embedded: the App View host). */
|
|
43
|
+
readonly host: ElementLike;
|
|
44
|
+
/** The document the renderer creates elements from. */
|
|
45
|
+
readonly doc: DocumentLike;
|
|
46
|
+
/** Fetches the latest SUPPLY report (e.g. over HTTP from the app's `/agentic/supply` endpoint). */
|
|
47
|
+
readonly fetchSupply: () => Promise<SupplyReport>;
|
|
48
|
+
/** Opens a socket to the app relay channel (one per drill-in connection). */
|
|
49
|
+
readonly connectRelay: SocketFactory;
|
|
50
|
+
/** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
|
|
51
|
+
readonly createTerminal: CreateTerminal;
|
|
52
|
+
/** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
|
|
53
|
+
readonly schedule?: Scheduler;
|
|
54
|
+
/** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must pair with {@link clearTimer}. */
|
|
55
|
+
readonly setTimer?: (run: () => void, ms: number) => TimerHandle;
|
|
56
|
+
/** Cancels a poll timer. Default `clearTimeout`. Must pair with {@link setTimer}. */
|
|
57
|
+
readonly clearTimer?: (handle: TimerHandle) => void;
|
|
58
|
+
/** Poll interval in ms. Default 2000. */
|
|
59
|
+
readonly refreshMs?: number;
|
|
60
|
+
/** Bulk credit granted per terminal (re)subscribe. Default 1024. */
|
|
61
|
+
readonly credit?: number;
|
|
62
|
+
/** A live worker idle longer than this (ms) grades `stale`. Default 15000. */
|
|
63
|
+
readonly staleAfterMs?: number;
|
|
64
|
+
/** Notified of a fetch/render/relay error (the poll keeps going). */
|
|
65
|
+
readonly onError?: (err: unknown) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The running supply cockpit; dispose to stop polling and tear down the terminal. */
|
|
69
|
+
export interface SupplyCockpitHandle {
|
|
70
|
+
/** Run one fetch→render pass now (also the poll body). Resolves when rendered. */
|
|
71
|
+
refresh(): Promise<void>;
|
|
72
|
+
/** Start the self-scheduling poll loop (runs one pass immediately). */
|
|
73
|
+
start(): void;
|
|
74
|
+
/** Stop the poll loop (leaves the last render in place). */
|
|
75
|
+
stop(): void;
|
|
76
|
+
/** Drill into a worker's relay stream, opening a resumable live terminal. */
|
|
77
|
+
drill(stream: string): void;
|
|
78
|
+
/** The stream currently drilled into, if any. */
|
|
79
|
+
readonly currentStream: string | undefined;
|
|
80
|
+
/** Stop everything and release the terminal connection. */
|
|
81
|
+
dispose(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const DEFAULT_REFRESH_MS = 2000;
|
|
85
|
+
|
|
86
|
+
function isPosInt(value: number): boolean {
|
|
87
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Drill {
|
|
91
|
+
readonly stream: string;
|
|
92
|
+
readonly client: RelayChannelClient;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class SupplyCockpit implements SupplyCockpitHandle {
|
|
96
|
+
readonly #env: SupplyCockpitEnv;
|
|
97
|
+
readonly #listRegion: ElementLike;
|
|
98
|
+
readonly #terminalHost: ElementLike;
|
|
99
|
+
readonly #refreshMs: number;
|
|
100
|
+
readonly #setTimer: (run: () => void, ms: number) => TimerHandle;
|
|
101
|
+
readonly #clearTimer: (handle: TimerHandle) => void;
|
|
102
|
+
readonly #timeouts = new Map<number, ReturnType<typeof setTimeout>>();
|
|
103
|
+
#nextTimerId = 0;
|
|
104
|
+
#timer: TimerHandle | undefined;
|
|
105
|
+
#running = false;
|
|
106
|
+
#disposed = false;
|
|
107
|
+
#drill: Drill | undefined;
|
|
108
|
+
// The currently mounted terminal, tracked so switching streams (and dispose) tears down the prior
|
|
109
|
+
// xterm instance instead of leaking it + its listeners.
|
|
110
|
+
#terminal: TerminalSink | undefined;
|
|
111
|
+
// Bumped by every start()/stop() so an in-flight #tick() from a previous start cycle can't
|
|
112
|
+
// reschedule after a stop→start race and leave two overlapping poll chains running.
|
|
113
|
+
#generation = 0;
|
|
114
|
+
|
|
115
|
+
constructor(env: SupplyCockpitEnv) {
|
|
116
|
+
this.#env = env;
|
|
117
|
+
this.#refreshMs = env.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
118
|
+
// refreshMs feeds setTimeout as a poll delay. A negative/NaN/fractional/unsafe value silently
|
|
119
|
+
// collapses to a ~0ms delay, turning the poll into a hot loop that hammers the endpoint. Require
|
|
120
|
+
// a positive safe integer up-front so a bad env option fails loudly instead.
|
|
121
|
+
if (!isPosInt(this.#refreshMs)) {
|
|
122
|
+
throw new RangeError(`SupplyCockpitEnv.refreshMs must be a positive safe integer, got ${this.#refreshMs}`);
|
|
123
|
+
}
|
|
124
|
+
// setTimer/clearTimer are a matched pair: a caller-supplied setTimer returns opaque handles the
|
|
125
|
+
// default clearTimer (which only understands the internal numeric-handle Map) cannot cancel,
|
|
126
|
+
// leaving an un-stoppable poll loop. Fail fast rather than silently accept one without the other.
|
|
127
|
+
if ((env.setTimer === undefined) !== (env.clearTimer === undefined)) {
|
|
128
|
+
throw new Error("SupplyCockpitEnv.setTimer and clearTimer must be provided together (or neither)");
|
|
129
|
+
}
|
|
130
|
+
this.#setTimer =
|
|
131
|
+
env.setTimer ??
|
|
132
|
+
((run, ms) => {
|
|
133
|
+
const id = this.#nextTimerId++;
|
|
134
|
+
this.#timeouts.set(
|
|
135
|
+
id,
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
this.#timeouts.delete(id);
|
|
138
|
+
run();
|
|
139
|
+
}, ms),
|
|
140
|
+
);
|
|
141
|
+
return id;
|
|
142
|
+
});
|
|
143
|
+
this.#clearTimer =
|
|
144
|
+
env.clearTimer ??
|
|
145
|
+
((handle) => {
|
|
146
|
+
if (typeof handle !== "number") return;
|
|
147
|
+
const timeout = this.#timeouts.get(handle);
|
|
148
|
+
if (timeout !== undefined) {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
this.#timeouts.delete(handle);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Build the stable skeleton once: a volatile list region the poll re-renders, and a PERSISTENT
|
|
155
|
+
// terminal region a refresh never touches.
|
|
156
|
+
env.host.replaceChildren();
|
|
157
|
+
const shell = env.doc.createElement("div");
|
|
158
|
+
shell.className = "cockpit-shell";
|
|
159
|
+
this.#listRegion = env.doc.createElement("div");
|
|
160
|
+
this.#listRegion.className = "cockpit-supply-region";
|
|
161
|
+
const terminalPanel = env.doc.createElement("section");
|
|
162
|
+
terminalPanel.className = "cockpit-terminal";
|
|
163
|
+
const title = env.doc.createElement("h2");
|
|
164
|
+
title.className = "cockpit-panel-title";
|
|
165
|
+
title.textContent = "Worker terminal";
|
|
166
|
+
terminalPanel.appendChild(title);
|
|
167
|
+
this.#terminalHost = env.doc.createElement("div");
|
|
168
|
+
this.#terminalHost.className = "cockpit-terminal-host";
|
|
169
|
+
this.#terminalHost.setAttribute("data-terminal", "host");
|
|
170
|
+
terminalPanel.appendChild(this.#terminalHost);
|
|
171
|
+
shell.appendChild(this.#listRegion);
|
|
172
|
+
shell.appendChild(terminalPanel);
|
|
173
|
+
env.host.appendChild(shell);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get currentStream(): string | undefined {
|
|
177
|
+
return this.#drill?.stream;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async refresh(): Promise<void> {
|
|
181
|
+
if (this.#disposed) return;
|
|
182
|
+
let report: SupplyReport;
|
|
183
|
+
try {
|
|
184
|
+
report = await this.#env.fetchSupply();
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.#env.onError?.(err);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (this.#disposed) return;
|
|
190
|
+
try {
|
|
191
|
+
renderSupply(this.#listRegion, this.#env.doc, supplyView(report, { staleAfterMs: this.#env.staleAfterMs }), {
|
|
192
|
+
onDrill: (stream) => this.drill(stream),
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
this.#env.onError?.(err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
start(): void {
|
|
200
|
+
if (this.#disposed || this.#running) return;
|
|
201
|
+
this.#running = true;
|
|
202
|
+
const generation = ++this.#generation;
|
|
203
|
+
this.#tick(generation);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
stop(): void {
|
|
207
|
+
this.#running = false;
|
|
208
|
+
// Invalidate any in-flight tick so its finally can't reschedule after this.
|
|
209
|
+
this.#generation++;
|
|
210
|
+
if (this.#timer !== undefined) {
|
|
211
|
+
this.#clearTimer(this.#timer);
|
|
212
|
+
this.#timer = undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#tick(generation: number): void {
|
|
217
|
+
// Self-scheduling: schedule the NEXT pass only after this one settles, so a slow fetch can never
|
|
218
|
+
// overlap its successor. The generation guard drops a stale tick whose start cycle was already
|
|
219
|
+
// stopped (and possibly restarted), so a stop→start race never leaves two poll chains scheduling.
|
|
220
|
+
void this.refresh().finally(() => {
|
|
221
|
+
if (generation !== this.#generation || !this.#running || this.#disposed) return;
|
|
222
|
+
this.#timer = this.#setTimer(() => this.#tick(generation), this.#refreshMs);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
drill(stream: string): void {
|
|
227
|
+
if (this.#disposed) return;
|
|
228
|
+
if (this.#drill?.stream === stream) return;
|
|
229
|
+
// Close and drop the prior drill up-front so a synchronous failure while building the new one
|
|
230
|
+
// (createTerminal, an invalid TerminalSession credit, or connect throwing) can't leave #drill
|
|
231
|
+
// pointing at an already-closed client. #drill is re-set only once the new client is fully wired.
|
|
232
|
+
this.#drill?.client.close();
|
|
233
|
+
this.#drill = undefined;
|
|
234
|
+
// Tear down the prior terminal before mounting a fresh one so repeated drills don't leak xterm
|
|
235
|
+
// instances/listeners (replaceChildren only drops the DOM node, not the widget).
|
|
236
|
+
this.#terminal?.dispose?.();
|
|
237
|
+
this.#terminal = undefined;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
// Fresh terminal for the newly selected worker.
|
|
241
|
+
this.#terminalHost.replaceChildren();
|
|
242
|
+
const sink = this.#env.createTerminal(this.#terminalHost);
|
|
243
|
+
this.#terminal = sink;
|
|
244
|
+
|
|
245
|
+
let session: TerminalSession | undefined;
|
|
246
|
+
const client = new RelayChannelClient({
|
|
247
|
+
connect: this.#env.connectRelay,
|
|
248
|
+
onRelay: (message) => session?.handle(message),
|
|
249
|
+
// Re-attach on EVERY (re)connect → resume-from-offset: the terminal survives a cockpit
|
|
250
|
+
// reconnect without losing or double-writing output.
|
|
251
|
+
onOpen: () => session?.attach(),
|
|
252
|
+
schedule: this.#env.schedule,
|
|
253
|
+
onError: (err) => this.#env.onError?.(err),
|
|
254
|
+
});
|
|
255
|
+
session = new TerminalSession({
|
|
256
|
+
stream,
|
|
257
|
+
sink,
|
|
258
|
+
send: (message) => client.sendRelay(message),
|
|
259
|
+
credit: this.#env.credit,
|
|
260
|
+
});
|
|
261
|
+
client.open();
|
|
262
|
+
this.#drill = { stream, client };
|
|
263
|
+
} catch (err) {
|
|
264
|
+
this.#env.onError?.(err);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
dispose(): void {
|
|
269
|
+
if (this.#disposed) return;
|
|
270
|
+
this.#disposed = true;
|
|
271
|
+
this.stop();
|
|
272
|
+
this.#drill?.client.close();
|
|
273
|
+
this.#drill = undefined;
|
|
274
|
+
this.#terminal?.dispose?.();
|
|
275
|
+
this.#terminal = undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Boot the supply cockpit against an injected environment. Call {@link SupplyCockpitHandle.start} to poll. */
|
|
280
|
+
export function bootSupplyCockpit(env: SupplyCockpitEnv): SupplyCockpitHandle {
|
|
281
|
+
return new SupplyCockpit(env);
|
|
282
|
+
}
|