@nanobpm/nano-workforce 0.69.1 → 0.70.1
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/AGENTS.md +18 -0
- package/CHANGELOG.md +14 -0
- package/README.md +10 -8
- package/app/agentic/cockpit/index.ts +18 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
- package/app/agentic/cockpit/supply-boot.test.ts +34 -0
- package/app/agentic/cockpit/supply-boot.ts +256 -21
- package/app/agentic/cockpit/transcript-render.test.ts +110 -0
- package/app/agentic/cockpit/transcript-render.ts +136 -0
- package/app/agentic/cockpit/transcript-view.test.ts +61 -0
- package/app/agentic/cockpit/transcript-view.ts +131 -0
- package/app/agentic/families/relay.family.test.ts +103 -0
- package/app/agentic/families/relay.family.ts +74 -0
- package/app/agentic/transcript-read.test.ts +72 -0
- package/app/agentic/transcript-read.ts +161 -0
- package/app/blackboard.test.ts +15 -7
- package/app/blackboard.ts +4 -4
- package/app/convergeGate.test.ts +406 -0
- package/app/convergeGate.ts +48 -0
- package/app/github.ts +225 -0
- package/app/roundProgress.test.ts +229 -0
- package/app/roundProgress.ts +70 -0
- package/app/service.test.ts +18 -1
- package/app/service.ts +5 -1
- package/db/migrations/033_pr_round_head.sql +16 -0
- package/nano.app.json +8 -0
- package/openapi.yaml +267 -0
- package/operations/getAgenticTranscript.test.ts +165 -0
- package/operations/getAgenticTranscript.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +169 -0
- package/operations/listAgenticTranscripts.ts +61 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +70 -0
- package/pages/cockpit/mount.js +254 -13
- package/pages/cockpit.page.json +1 -1
- package/prompts/review-round.md +55 -9
- package/resources/processes/convergence-loop.bpmn +236 -65
- package/workers/converge-gate/worker.ts +101 -0
- package/workers/progress-check/worker.ts +77 -0
package/AGENTS.md
CHANGED
|
@@ -261,3 +261,21 @@ agents:
|
|
|
261
261
|
PR and reference the closing issue (`Closes #NN`).
|
|
262
262
|
- **Never `git push --force` on `main`;** use `--force-with-lease` on feature
|
|
263
263
|
branches.
|
|
264
|
+
|
|
265
|
+
## Distributed fleet: NANO_WORKFORCE_BASE_URL
|
|
266
|
+
|
|
267
|
+
The abandon and blackboard hooks are how a **distributed worker fleet** calls back
|
|
268
|
+
into this app. Their capability URLs (`abandonUrl`, `blackboardUrl`) are minted from a
|
|
269
|
+
single knob, **`NANO_WORKFORCE_BASE_URL`** (`app/blackboard.ts` `publicBaseUrl()`,
|
|
270
|
+
default `http://localhost:3000`), and are **baked into process variables at
|
|
271
|
+
instance-seed time** (`app/service.ts`), then rendered into each remote agent's prompt.
|
|
272
|
+
|
|
273
|
+
- The value **must be reachable from wherever the worker runs** — `localhost` only
|
|
274
|
+
works for a co-located agent, never for a remote fleet machine. Use a LAN IP /
|
|
275
|
+
hostname the fleet can reach.
|
|
276
|
+
- When the app runs **embedded behind the nano console**, the base **must include the
|
|
277
|
+
reverse-proxy prefix**, e.g.
|
|
278
|
+
`http://<merlin-lan-ip>:<console-port>/console/app-view/Workforce`. `abandonUrl()`
|
|
279
|
+
then appends `/app/api/hooks/abandon?token=...`.
|
|
280
|
+
- The base is **captured at instance-seed time**. Changing `NANO_WORKFORCE_BASE_URL`
|
|
281
|
+
later does **not** heal already-running instances — re-seed to pick up the new value.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.70.1](https://github.com/nanobpm/nano-workforce/compare/v0.70.0...v0.70.1) (2026-08-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **convergence:** don't re-review a no-progress round ([#230](https://github.com/nanobpm/nano-workforce/issues/230)) ([a8ea844](https://github.com/nanobpm/nano-workforce/commit/a8ea84444b351989f716124d0b485b42e1ec2a35)), closes [#231](https://github.com/nanobpm/nano-workforce/issues/231) [Magikcraft/nano-bpm#770](https://github.com/Magikcraft/nano-bpm/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770) [nanobpm/nano-workforce#225](https://github.com/nanobpm/nano-workforce/issues/225) [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#770](https://github.com/nanobpm/nano-workforce/issues/770)
|
|
7
|
+
|
|
8
|
+
# [0.70.0](https://github.com/nanobpm/nano-workforce/compare/v0.69.1...v0.70.0) (2026-08-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **agentic:** transcript read path + cockpit past-session replay ([#222](https://github.com/nanobpm/nano-workforce/issues/222)) ([#225](https://github.com/nanobpm/nano-workforce/issues/225)) ([a93cdd0](https://github.com/nanobpm/nano-workforce/commit/a93cdd0824a3afcb44feffdb2dafc7732154545e)), closes [#refreshPast](https://github.com/nanobpm/nano-workforce/issues/refreshPast) [mode/#shownStream](https://github.com/nanobpm/nano-workforce/issues/shownStream) [#refreshPast](https://github.com/nanobpm/nano-workforce/issues/refreshPast)
|
|
14
|
+
|
|
1
15
|
## [0.69.1](https://github.com/nanobpm/nano-workforce/compare/v0.69.0...v0.69.1) (2026-08-15)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -263,7 +263,7 @@ active epic already targets the same custom base. See
|
|
|
263
263
|
| `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
|
|
264
264
|
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | `PT20M` | ISO-8601 duration the loop waits for a fresh review before escalating a stalled review (timer arm of the `wait-review` gateway) |
|
|
265
265
|
| `NANO_PR_REVIEW_NUDGE_MINUTES` | `5` | cooldown between the poller's automatic reviewer re-request nudges for one waiting PR (clamped 1–1440) |
|
|
266
|
-
| `
|
|
266
|
+
| `NANO_WORKFORCE_BASE_URL` | `http://localhost:3000` | externally-reachable base URL for the capability hooks (`/app/api/hooks/*`). Must resolve from **wherever the agent runs** — set it to the app's LAN address (or console-proxy URL) for a remote fleet. See [Fleet networking](#fleet-networking-remote-workers) |
|
|
267
267
|
| `NANO_AGENTIC_SECRET` | — | enables **secure mode** for the agentic visibility channel (`/agentic`): requires an ADR 0028 identity token + capability credential from every peer. Required to attach agentic visibility from **off-box** workers; unset = on-by-default **LOCAL mode** (well-known token, loopback peers only). Also accepts `NANO_PR_WEBHOOK_SECRET` |
|
|
268
268
|
|
|
269
269
|
### Fleet networking (remote workers)
|
|
@@ -275,8 +275,10 @@ machines. Two app surfaces must be reachable from those off-box workers:
|
|
|
275
275
|
side-effecting agent is handed an unguessable per-run capability URL in its prompt and `curl`s it
|
|
276
276
|
before each irreversible action (an unknown token is a `404`). A remote worker can only reach these
|
|
277
277
|
if (a) the app's HTTP server is bound so off-box hosts can connect, and (b) the base URL baked into
|
|
278
|
-
that prompt resolves from the worker's host — hence **`
|
|
279
|
-
LAN address, not `localhost`**.
|
|
278
|
+
that prompt resolves from the worker's host — hence **`NANO_WORKFORCE_BASE_URL` must be the app's
|
|
279
|
+
LAN address, not `localhost`**. This base is **captured at instance-seed time** and baked into each
|
|
280
|
+
agent's prompt, so changing it later does **not** heal already-running instances — re-seed them to
|
|
281
|
+
pick up the new base.
|
|
280
282
|
- The **agentic visibility channel** — `/agentic` (WebSocket). In on-by-default **LOCAL mode** it is
|
|
281
283
|
gated only by a *well-known, non-secret* localhost token, so it is enforced **loopback-only**: an
|
|
282
284
|
off-box peer is refused — as is a **reverse-proxied** peer (a connection carrying an
|
|
@@ -310,22 +312,22 @@ nwf composes with two deployment topologies — pick one and point the fleet at
|
|
|
310
312
|
|
|
311
313
|
| Path | When | Fleet uses |
|
|
312
314
|
|---|---|---|
|
|
313
|
-
| **Direct LAN bind** | nwf run standalone for a fleet | Bind the server wide (above) and set `
|
|
314
|
-
| **Console reverse-proxy** | nwf embedded behind the nano console at `/console/app-view/Workforce` | Leave the app bound to loopback and set `
|
|
315
|
+
| **Direct LAN bind** | nwf run standalone for a fleet | Bind the server wide (above) and set `NANO_WORKFORCE_BASE_URL=http://<app-lan-host>:3000`. Workers `curl` the hooks directly on the app's LAN address. |
|
|
316
|
+
| **Console reverse-proxy** | nwf embedded behind the nano console at `/console/app-view/Workforce` | Leave the app bound to loopback and set `NANO_WORKFORCE_BASE_URL` to the console's public origin + the app-view prefix, so `/app/api/hooks/*` resolves through the proxy. Workers reach the hooks via the console. |
|
|
315
317
|
|
|
316
|
-
Either way the capability URL in each agent's prompt (`${
|
|
318
|
+
Either way the capability URL in each agent's prompt (`${NANO_WORKFORCE_BASE_URL}/app/api/hooks/…`)
|
|
317
319
|
must resolve from the worker's host. **Verify** from a fleet host before relying on it:
|
|
318
320
|
|
|
319
321
|
```sh
|
|
320
322
|
# From a remote LAN worker, against the base URL seeded into agent prompts.
|
|
321
323
|
# A live run returns { "prKey": "...", "status": "...", "abandoned": false }; -f exits non-zero on 404.
|
|
322
|
-
curl -fsS "${
|
|
324
|
+
curl -fsS "${NANO_WORKFORCE_BASE_URL}/app/api/hooks/abandon?token=<per-run-token>"
|
|
323
325
|
```
|
|
324
326
|
|
|
325
327
|
If the `curl` cannot reach the host, off-box agents will (correctly, per the abort contract) treat the
|
|
326
328
|
run as abandoned and stop — the exact failure behind
|
|
327
329
|
[`jwulf/c8ctl-plugin-nano#76`](https://github.com/jwulf/c8ctl-plugin-nano/issues/76). Fix it by
|
|
328
|
-
binding wide + setting a routable `
|
|
330
|
+
binding wide + setting a routable `NANO_WORKFORCE_BASE_URL`, or by fronting nwf with the console proxy.
|
|
329
331
|
|
|
330
332
|
|
|
331
333
|
### Purge
|
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
type CreateTerminal,
|
|
11
11
|
type SupplyCockpitEnv,
|
|
12
12
|
type SupplyCockpitHandle,
|
|
13
|
+
type TerminalMode,
|
|
13
14
|
type TimerHandle,
|
|
14
15
|
} from "./supply-boot.ts";
|
|
15
16
|
export {
|
|
@@ -28,3 +29,20 @@ export {
|
|
|
28
29
|
type SupplyWorkerView,
|
|
29
30
|
supplyView,
|
|
30
31
|
} from "./supply-view.ts";
|
|
32
|
+
export {
|
|
33
|
+
type RenderTranscriptsOptions,
|
|
34
|
+
renderTranscripts,
|
|
35
|
+
replayTranscript,
|
|
36
|
+
type TranscriptChunkReport,
|
|
37
|
+
type TranscriptDataReport,
|
|
38
|
+
type TranscriptsDom,
|
|
39
|
+
} from "./transcript-render.ts";
|
|
40
|
+
export {
|
|
41
|
+
humanBytes,
|
|
42
|
+
humanDuration,
|
|
43
|
+
type TranscriptListReport,
|
|
44
|
+
type TranscriptSummaryReport,
|
|
45
|
+
type TranscriptsView,
|
|
46
|
+
type TranscriptView,
|
|
47
|
+
transcriptsView,
|
|
48
|
+
} from "./transcript-view.ts";
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// Unit tests for the SUPPLY cockpit boot layer's "past sessions" replay (H3 read path / #222).
|
|
2
|
+
//
|
|
3
|
+
// The acceptance's testable heart for #222: the cockpit renders a past-sessions history list beside the
|
|
4
|
+
// live supply list; selecting a past session statically replays its stored transcript into the SAME
|
|
5
|
+
// persistent terminal region (no live worker, no relay socket); and live vs replayed is clearly
|
|
6
|
+
// distinguished (currentMode + the panel's data-terminal-mode). No real browser, no real socket.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
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
|
+
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
13
|
+
import type { TranscriptListReport } from "./transcript-view.ts";
|
|
14
|
+
|
|
15
|
+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
16
|
+
|
|
17
|
+
const supply: SupplyReport = {
|
|
18
|
+
count: 1,
|
|
19
|
+
workers: [{ instance: "wk-a", identity: "leaf-1", stream: "job:live", family: "senior", host: "h1", jobKeys: ["live"], live: true, staleMs: 0 }],
|
|
20
|
+
leaves: [{ token: "leaf-1", workers: [{ instance: "wk-a", identity: "leaf-1", stream: "job:live", family: "senior", host: "h1", jobKeys: ["live"], live: true, staleMs: 0 }] }],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const transcripts: TranscriptListReport = {
|
|
24
|
+
count: 1,
|
|
25
|
+
retentionMs: 86_400_000,
|
|
26
|
+
transcripts: [
|
|
27
|
+
{ stream: "job:past", lifecycle: "ephemeral", status: "completed", createdAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:05:00Z", nextOffset: 2, byteLength: 4, chunkCount: 2, jobKey: "past", planKey: "o/r#1" },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const transcriptData: TranscriptDataReport = {
|
|
32
|
+
stream: "job:past",
|
|
33
|
+
from: 0,
|
|
34
|
+
gap: false,
|
|
35
|
+
nextOffset: 2,
|
|
36
|
+
entries: [
|
|
37
|
+
{ offset: 0, chunk: "PA" },
|
|
38
|
+
{ offset: 1, chunk: "ST" },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface Rig {
|
|
43
|
+
readonly env: SupplyCockpitEnv;
|
|
44
|
+
readonly host: FakeElement;
|
|
45
|
+
readonly sockets: FakeSocket[];
|
|
46
|
+
readonly terminalWrites: string[];
|
|
47
|
+
terminalMounts: number;
|
|
48
|
+
terminalDisposes: number;
|
|
49
|
+
fetchedTranscript: string | undefined;
|
|
50
|
+
errors: unknown[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rig(withTranscripts = true): Rig {
|
|
54
|
+
const host = new FakeElement("body");
|
|
55
|
+
const sockets: FakeSocket[] = [];
|
|
56
|
+
const terminalWrites: string[] = [];
|
|
57
|
+
const state: Rig = {
|
|
58
|
+
host,
|
|
59
|
+
sockets,
|
|
60
|
+
terminalWrites,
|
|
61
|
+
terminalMounts: 0,
|
|
62
|
+
terminalDisposes: 0,
|
|
63
|
+
fetchedTranscript: undefined,
|
|
64
|
+
errors: [],
|
|
65
|
+
env: {
|
|
66
|
+
host,
|
|
67
|
+
doc: new FakeDocument(),
|
|
68
|
+
fetchSupply: () => Promise.resolve(supply),
|
|
69
|
+
...(withTranscripts
|
|
70
|
+
? {
|
|
71
|
+
fetchTranscripts: () => Promise.resolve(transcripts),
|
|
72
|
+
fetchTranscript: (stream: string) => {
|
|
73
|
+
state.fetchedTranscript = stream;
|
|
74
|
+
return Promise.resolve(transcriptData);
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
connectRelay: () => {
|
|
79
|
+
const s = new FakeSocket();
|
|
80
|
+
sockets.push(s);
|
|
81
|
+
return s;
|
|
82
|
+
},
|
|
83
|
+
createTerminal: (terminalHost) => {
|
|
84
|
+
state.terminalMounts += 1;
|
|
85
|
+
terminalHost.appendChild(new FakeElement("pre"));
|
|
86
|
+
return {
|
|
87
|
+
write: (chunk) => terminalWrites.push(chunk),
|
|
88
|
+
dispose: () => {
|
|
89
|
+
state.terminalDisposes += 1;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
setTimer: () => 0,
|
|
94
|
+
clearTimer: () => {},
|
|
95
|
+
onError: (err) => state.errors.push(err),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("refresh renders the past-sessions history list beside the live supply list", async () => {
|
|
102
|
+
const r = rig();
|
|
103
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
104
|
+
await cockpit.refresh();
|
|
105
|
+
await flush();
|
|
106
|
+
assert.equal(r.host.byData("worker", "wk-a").length, 1, "live supply list rendered");
|
|
107
|
+
assert.equal(r.host.byData("stream", "job:past").length >= 1, true, "past sessions list rendered");
|
|
108
|
+
assert.equal(r.host.byData("session-count", "1").length, 1);
|
|
109
|
+
assert.deepEqual(r.errors, []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("replaying a past session feeds its stored bytes into the terminal, in replay mode", async () => {
|
|
113
|
+
const r = rig();
|
|
114
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
115
|
+
await cockpit.refresh();
|
|
116
|
+
await flush();
|
|
117
|
+
|
|
118
|
+
await cockpit.replay("job:past");
|
|
119
|
+
assert.equal(r.fetchedTranscript, "job:past");
|
|
120
|
+
assert.equal(cockpit.currentStream, "job:past");
|
|
121
|
+
assert.equal(cockpit.currentMode, "replay");
|
|
122
|
+
assert.deepEqual(r.terminalWrites, ["PA", "ST"], "the stored transcript renders faithfully");
|
|
123
|
+
// No relay socket opened for a static replay.
|
|
124
|
+
assert.equal(r.sockets.length, 0);
|
|
125
|
+
// The panel plainly marks the region as a replayed past session.
|
|
126
|
+
assert.equal(r.host.byData("terminal-mode", "replay").length, 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("clicking a past-session button drives a replay", async () => {
|
|
130
|
+
const r = rig();
|
|
131
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
132
|
+
await cockpit.refresh();
|
|
133
|
+
await flush();
|
|
134
|
+
const button = r.host.byClass("cockpit-past-replay").find((b) => b.getAttribute("data-stream") === "job:past");
|
|
135
|
+
button?.dispatch("click");
|
|
136
|
+
await flush();
|
|
137
|
+
assert.equal(cockpit.currentMode, "replay");
|
|
138
|
+
assert.deepEqual(r.terminalWrites, ["PA", "ST"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("replaying after a live drill tears the live stream down and switches to replay", async () => {
|
|
142
|
+
const r = rig();
|
|
143
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
144
|
+
await cockpit.refresh();
|
|
145
|
+
await flush();
|
|
146
|
+
|
|
147
|
+
cockpit.drill("job:live");
|
|
148
|
+
assert.equal(cockpit.currentMode, "live");
|
|
149
|
+
assert.equal(r.sockets.length, 1);
|
|
150
|
+
|
|
151
|
+
await cockpit.replay("job:past");
|
|
152
|
+
assert.equal(cockpit.currentMode, "replay");
|
|
153
|
+
assert.equal(r.sockets[0]?.closed, true, "the live relay socket is closed on switching to replay");
|
|
154
|
+
assert.equal(cockpit.currentStream, "job:past");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("the past-sessions panel is absent when no transcript source is injected (live-only cockpit)", async () => {
|
|
158
|
+
const r = rig(false);
|
|
159
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
160
|
+
await cockpit.refresh();
|
|
161
|
+
await flush();
|
|
162
|
+
assert.equal(r.host.byData("session-count").length, 0, "no past-sessions region rendered");
|
|
163
|
+
assert.equal(cockpit.currentMode, undefined);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a slow replay fetch that resolves after a newer drill does not clobber the live terminal", async () => {
|
|
167
|
+
const r = rig();
|
|
168
|
+
// A replay fetch we resolve by hand, so the drill can land first while it is still outstanding.
|
|
169
|
+
let releaseReplay: (() => void) | undefined;
|
|
170
|
+
const gated: SupplyCockpitEnv = {
|
|
171
|
+
...r.env,
|
|
172
|
+
fetchTranscript: (stream: string) => {
|
|
173
|
+
r.fetchedTranscript = stream;
|
|
174
|
+
return new Promise<TranscriptDataReport>((resolve) => {
|
|
175
|
+
releaseReplay = () => resolve(transcriptData);
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const cockpit = bootSupplyCockpit(gated);
|
|
180
|
+
await cockpit.refresh();
|
|
181
|
+
await flush();
|
|
182
|
+
|
|
183
|
+
// Start a replay whose fetch is still pending, then drill into a live worker.
|
|
184
|
+
const replaying = cockpit.replay("job:past");
|
|
185
|
+
cockpit.drill("job:live");
|
|
186
|
+
assert.equal(cockpit.currentMode, "live");
|
|
187
|
+
assert.equal(r.sockets.length, 1);
|
|
188
|
+
|
|
189
|
+
// The stale replay now resolves — it must NOT overwrite the live terminal or close the live socket.
|
|
190
|
+
releaseReplay?.();
|
|
191
|
+
await replaying;
|
|
192
|
+
await flush();
|
|
193
|
+
assert.equal(cockpit.currentMode, "live", "the newer live drill wins over the stale replay");
|
|
194
|
+
assert.equal(cockpit.currentStream, "job:live");
|
|
195
|
+
assert.equal(r.sockets[0]?.closed, false, "the live relay socket is not torn down by a stale replay");
|
|
196
|
+
assert.deepEqual(r.terminalWrites, [], "the stale transcript bytes are never rendered");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a hanging transcripts fetch does not stall the supply poll", async () => {
|
|
200
|
+
const r = rig();
|
|
201
|
+
const stalled: SupplyCockpitEnv = {
|
|
202
|
+
...r.env,
|
|
203
|
+
// Never resolves: before the fix, refresh() awaited this and wedged the poll forever.
|
|
204
|
+
fetchTranscripts: () => new Promise<TranscriptListReport>(() => {}),
|
|
205
|
+
};
|
|
206
|
+
const cockpit = bootSupplyCockpit(stalled);
|
|
207
|
+
|
|
208
|
+
// refresh() must resolve despite the hung transcripts fetch, having rendered the live supply list.
|
|
209
|
+
await cockpit.refresh();
|
|
210
|
+
assert.equal(r.host.byData("worker", "wk-a").length, 1, "live supply list rendered despite the hang");
|
|
211
|
+
|
|
212
|
+
// A second pass still renders the live list (single-flight skips the still-pending past-fetch).
|
|
213
|
+
await cockpit.refresh();
|
|
214
|
+
assert.equal(r.host.byData("worker", "wk-a").length, 1, "supply poll keeps making progress");
|
|
215
|
+
assert.deepEqual(r.errors, []);
|
|
216
|
+
cockpit.dispose();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a permanently hung transcripts fetch is bounded by a timeout so the past panel recovers on the next poll", async () => {
|
|
220
|
+
const r = rig();
|
|
221
|
+
// A hand-driven timer seam lets us fire the bounded-wait timeout deterministically.
|
|
222
|
+
const timers = new Map<number, () => void>();
|
|
223
|
+
let nextId = 0;
|
|
224
|
+
let fetchCalls = 0;
|
|
225
|
+
let resolveSecond: ((v: TranscriptListReport) => void) | undefined;
|
|
226
|
+
const env: SupplyCockpitEnv = {
|
|
227
|
+
...r.env,
|
|
228
|
+
setTimer: (run) => {
|
|
229
|
+
const id = nextId++;
|
|
230
|
+
timers.set(id, run);
|
|
231
|
+
return id;
|
|
232
|
+
},
|
|
233
|
+
clearTimer: (handle) => {
|
|
234
|
+
timers.delete(handle as number);
|
|
235
|
+
},
|
|
236
|
+
pastFetchTimeoutMs: 5000,
|
|
237
|
+
fetchTranscripts: () => {
|
|
238
|
+
fetchCalls += 1;
|
|
239
|
+
// The first past-fetch hangs forever; the second (post-recovery) resolves under our control.
|
|
240
|
+
if (fetchCalls === 1) return new Promise<TranscriptListReport>(() => {});
|
|
241
|
+
return new Promise<TranscriptListReport>((resolve) => {
|
|
242
|
+
resolveSecond = resolve;
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
const cockpit = bootSupplyCockpit(env);
|
|
247
|
+
|
|
248
|
+
await cockpit.refresh(); // starts the hung past-fetch #1 and schedules its timeout timer
|
|
249
|
+
await flush();
|
|
250
|
+
assert.equal(fetchCalls, 1);
|
|
251
|
+
|
|
252
|
+
// While the (hung) fetch is outstanding and its timeout has not fired, single-flight blocks a retry.
|
|
253
|
+
await cockpit.refresh();
|
|
254
|
+
await flush();
|
|
255
|
+
assert.equal(fetchCalls, 1, "single-flight: no retry while the outstanding past-fetch is still pending");
|
|
256
|
+
|
|
257
|
+
// Fire the bounded-wait timeout: the wait rejects, surfacing one error and clearing the single-flight flag.
|
|
258
|
+
for (const run of [...timers.values()]) run();
|
|
259
|
+
await flush();
|
|
260
|
+
assert.equal(r.errors.length, 1, "the bounded wait surfaces the timeout as one error");
|
|
261
|
+
|
|
262
|
+
// The past panel has recovered: the next poll starts a fresh past-fetch (the flag is no longer stuck).
|
|
263
|
+
await cockpit.refresh();
|
|
264
|
+
await flush();
|
|
265
|
+
assert.equal(fetchCalls, 2, "after the timeout the single-flight flag clears, so the next poll retries");
|
|
266
|
+
|
|
267
|
+
// And that fresh fetch renders the past-sessions list.
|
|
268
|
+
resolveSecond?.(transcripts);
|
|
269
|
+
await flush();
|
|
270
|
+
assert.equal(r.host.byData("stream", "job:past").length >= 1, true, "the recovered past-sessions list renders");
|
|
271
|
+
cockpit.dispose();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a permanently hung replay fetch is bounded by a timeout so replay() always settles", async () => {
|
|
275
|
+
const r = rig();
|
|
276
|
+
// A hand-driven timer seam lets us fire the bounded-wait timeout deterministically.
|
|
277
|
+
const timers = new Map<number, () => void>();
|
|
278
|
+
let nextId = 0;
|
|
279
|
+
const env: SupplyCockpitEnv = {
|
|
280
|
+
...r.env,
|
|
281
|
+
setTimer: (run) => {
|
|
282
|
+
const id = nextId++;
|
|
283
|
+
timers.set(id, run);
|
|
284
|
+
return id;
|
|
285
|
+
},
|
|
286
|
+
clearTimer: (handle) => {
|
|
287
|
+
timers.delete(handle as number);
|
|
288
|
+
},
|
|
289
|
+
pastFetchTimeoutMs: 5000,
|
|
290
|
+
// The replay transcript fetch hangs forever: before the fix, replay() awaited it and never settled,
|
|
291
|
+
// leaving the terminal wedged out of live mode with an in-flight request that never completes.
|
|
292
|
+
fetchTranscript: () => new Promise<TranscriptDataReport>(() => {}),
|
|
293
|
+
};
|
|
294
|
+
const cockpit = bootSupplyCockpit(env);
|
|
295
|
+
await cockpit.refresh(); // the list fetch resolves, clearing its bounded timer
|
|
296
|
+
await flush();
|
|
297
|
+
|
|
298
|
+
// Start a replay whose transcript fetch never settles; it must not hang forever.
|
|
299
|
+
const replaying = cockpit.replay("job:past");
|
|
300
|
+
await flush();
|
|
301
|
+
|
|
302
|
+
// Fire the bounded-wait timeout: replay() must settle (reject → caught), surfacing the timeout as an
|
|
303
|
+
// error, rather than hanging the caller forever.
|
|
304
|
+
for (const run of [...timers.values()]) run();
|
|
305
|
+
await replaying;
|
|
306
|
+
await flush();
|
|
307
|
+
assert.equal(r.errors.length >= 1, true, "the bounded replay wait surfaces the timeout as an error");
|
|
308
|
+
assert.equal(cockpit.currentMode, undefined, "a timed-out replay leaves the terminal idle, not stuck in replay");
|
|
309
|
+
cockpit.dispose();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("the bounded-wait timeout clears its own timer handle (no leak / no double-fire under custom timers)", async () => {
|
|
313
|
+
const r = rig();
|
|
314
|
+
// A hand-driven timer seam: setTimer records the handle, clearTimer records + drops it. Only clearTimer
|
|
315
|
+
// removes a handle from the map — firing a timer callback does not — so a surviving handle after the
|
|
316
|
+
// timeout arm fires is a genuine leak the custom clearTimer never got to reclaim.
|
|
317
|
+
const timers = new Map<number, () => void>();
|
|
318
|
+
const cleared: number[] = [];
|
|
319
|
+
let nextId = 0;
|
|
320
|
+
const env: SupplyCockpitEnv = {
|
|
321
|
+
...r.env,
|
|
322
|
+
setTimer: (run) => {
|
|
323
|
+
const id = nextId++;
|
|
324
|
+
timers.set(id, run);
|
|
325
|
+
return id;
|
|
326
|
+
},
|
|
327
|
+
clearTimer: (handle) => {
|
|
328
|
+
cleared.push(handle as number);
|
|
329
|
+
timers.delete(handle as number);
|
|
330
|
+
},
|
|
331
|
+
pastFetchTimeoutMs: 5000,
|
|
332
|
+
// The replay fetch hangs forever, so the bounded wait settles via its TIMEOUT arm (not the fetch arm).
|
|
333
|
+
fetchTranscript: () => new Promise<TranscriptDataReport>(() => {}),
|
|
334
|
+
};
|
|
335
|
+
const cockpit = bootSupplyCockpit(env);
|
|
336
|
+
await cockpit.refresh(); // the list fetch resolves, clearing its own bounded timer
|
|
337
|
+
await flush();
|
|
338
|
+
|
|
339
|
+
// Capture exactly the timer the replay's bounded wait schedules.
|
|
340
|
+
const before = new Set(timers.keys());
|
|
341
|
+
const replaying = cockpit.replay("job:past");
|
|
342
|
+
await flush();
|
|
343
|
+
const replayTimers = [...timers.keys()].filter((id) => !before.has(id));
|
|
344
|
+
assert.equal(replayTimers.length, 1, "replay scheduled exactly one bounded-wait timer");
|
|
345
|
+
const replayTimerId = replayTimers[0];
|
|
346
|
+
|
|
347
|
+
// Fire the timeout arm: the wait rejects and replay() settles.
|
|
348
|
+
timers.get(replayTimerId)?.();
|
|
349
|
+
await replaying;
|
|
350
|
+
await flush();
|
|
351
|
+
|
|
352
|
+
// The timeout arm MUST clear its own handle — otherwise a custom scheduler leaks it and can re-fire it.
|
|
353
|
+
assert.ok(cleared.includes(replayTimerId), "the timeout arm called clearTimer on its own handle");
|
|
354
|
+
assert.equal(timers.has(replayTimerId), false, "no leaked timer handle survives the timeout");
|
|
355
|
+
cockpit.dispose();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a synchronously-throwing fetch clears the bounded-wait timer (no leaked handle) and still settles", async () => {
|
|
359
|
+
const r = rig();
|
|
360
|
+
// Same hand-driven timer seam as the timeout-clear test: only clearTimer removes a handle from the map,
|
|
361
|
+
// so a handle that survives after the bounded wait settles is a genuine leak the custom scheduler never
|
|
362
|
+
// reclaimed — and, worse, one it could later re-fire.
|
|
363
|
+
const timers = new Map<number, () => void>();
|
|
364
|
+
const cleared: number[] = [];
|
|
365
|
+
let nextId = 0;
|
|
366
|
+
const boom = new Error("fetchTranscript threw synchronously");
|
|
367
|
+
const env: SupplyCockpitEnv = {
|
|
368
|
+
...r.env,
|
|
369
|
+
setTimer: (run) => {
|
|
370
|
+
const id = nextId++;
|
|
371
|
+
timers.set(id, run);
|
|
372
|
+
return id;
|
|
373
|
+
},
|
|
374
|
+
clearTimer: (handle) => {
|
|
375
|
+
cleared.push(handle as number);
|
|
376
|
+
timers.delete(handle as number);
|
|
377
|
+
},
|
|
378
|
+
pastFetchTimeoutMs: 5000,
|
|
379
|
+
// The replay fetch throws SYNCHRONOUSLY (not a rejected promise): before the fix the Promise executor's
|
|
380
|
+
// throw rejected the bounded wait, but its timeout handle was never cleared — it leaked and would fire
|
|
381
|
+
// (and under a custom scheduler could re-fire) long after replay() had already settled.
|
|
382
|
+
fetchTranscript: () => {
|
|
383
|
+
throw boom;
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
const cockpit = bootSupplyCockpit(env);
|
|
387
|
+
await cockpit.refresh(); // the list fetch resolves, clearing its own bounded timer
|
|
388
|
+
await flush();
|
|
389
|
+
|
|
390
|
+
// Capture exactly the timer the replay's bounded wait schedules. Track every handle seen so far (live
|
|
391
|
+
// or already-cleared) so the earlier list-fetch's cleared timer isn't miscounted as the replay's.
|
|
392
|
+
const before = new Set<number>([...timers.keys(), ...cleared]);
|
|
393
|
+
await cockpit.replay("job:past"); // must settle (reject → caught), not throw out of replay()
|
|
394
|
+
await flush();
|
|
395
|
+
const replayTimers = [...new Set([...timers.keys(), ...cleared])].filter((id) => !before.has(id));
|
|
396
|
+
assert.equal(replayTimers.length, 1, "replay scheduled exactly one bounded-wait timer");
|
|
397
|
+
const replayTimerId = replayTimers[0];
|
|
398
|
+
|
|
399
|
+
// The sync-throw arm MUST clear its own handle — otherwise a custom scheduler leaks it and can re-fire it.
|
|
400
|
+
assert.ok(cleared.includes(replayTimerId), "the sync-throw arm called clearTimer on its own handle");
|
|
401
|
+
assert.equal(timers.has(replayTimerId), false, "no leaked timer handle survives a synchronous fetch throw");
|
|
402
|
+
// The synchronous failure surfaced as an error and left the terminal idle, not stuck in replay.
|
|
403
|
+
assert.ok(r.errors.includes(boom), "the synchronous fetch throw surfaced as the replay error");
|
|
404
|
+
assert.equal(cockpit.currentMode, undefined, "a sync-throwing replay leaves the terminal idle, not stuck in replay");
|
|
405
|
+
cockpit.dispose();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("a supply fetch that rejects AFTER dispose does not surface onError to the torn-down cockpit", async () => {
|
|
409
|
+
// The success path guards the render with `if (this.#disposed) return;` after awaiting the fetch, but
|
|
410
|
+
// the catch must honour the same guard: an in-flight fetch that rejects post-dispose must not report an
|
|
411
|
+
// error into a UI that is already gone. Drive fetchSupply with a promise we reject only after dispose().
|
|
412
|
+
const r = rig();
|
|
413
|
+
let rejectSupply: ((err: unknown) => void) | undefined;
|
|
414
|
+
const env: SupplyCockpitEnv = {
|
|
415
|
+
...r.env,
|
|
416
|
+
fetchSupply: () => new Promise<SupplyReport>((_resolve, reject) => {
|
|
417
|
+
rejectSupply = reject;
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
const cockpit = bootSupplyCockpit(env);
|
|
421
|
+
|
|
422
|
+
const refreshing = cockpit.refresh(); // awaits the (pending) supply fetch
|
|
423
|
+
await flush();
|
|
424
|
+
cockpit.dispose(); // tear the cockpit down while the fetch is still outstanding
|
|
425
|
+
rejectSupply?.(new Error("supply endpoint died after teardown"));
|
|
426
|
+
await refreshing;
|
|
427
|
+
await flush();
|
|
428
|
+
|
|
429
|
+
assert.deepEqual(r.errors, [], "a post-dispose supply rejection is swallowed, not surfaced to a dead UI");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a hung past-list fetch that times out AFTER dispose does not surface onError", async () => {
|
|
433
|
+
// Same class as the supply path: #refreshPast()'s catch runs when the bounded wait rejects. If dispose()
|
|
434
|
+
// already fired, surfacing that timeout as an error reports into a torn-down cockpit. Drive the bounded
|
|
435
|
+
// timeout by hand and fire it only after dispose().
|
|
436
|
+
const r = rig();
|
|
437
|
+
const timers = new Map<number, () => void>();
|
|
438
|
+
let nextId = 0;
|
|
439
|
+
const env: SupplyCockpitEnv = {
|
|
440
|
+
...r.env,
|
|
441
|
+
setTimer: (run) => {
|
|
442
|
+
const id = nextId++;
|
|
443
|
+
timers.set(id, run);
|
|
444
|
+
return id;
|
|
445
|
+
},
|
|
446
|
+
clearTimer: (handle) => {
|
|
447
|
+
timers.delete(handle as number);
|
|
448
|
+
},
|
|
449
|
+
pastFetchTimeoutMs: 5000,
|
|
450
|
+
fetchTranscripts: () => new Promise<TranscriptListReport>(() => {}), // hangs forever
|
|
451
|
+
};
|
|
452
|
+
const cockpit = bootSupplyCockpit(env);
|
|
453
|
+
|
|
454
|
+
await cockpit.refresh(); // starts the hung past-fetch and schedules its bounded timeout
|
|
455
|
+
await flush();
|
|
456
|
+
cockpit.dispose(); // tear down while the past-fetch is still outstanding
|
|
457
|
+
for (const run of [...timers.values()]) run(); // fire the bounded timeout -> the wait rejects
|
|
458
|
+
await flush();
|
|
459
|
+
|
|
460
|
+
assert.deepEqual(r.errors, [], "a post-dispose past-list timeout is swallowed, not surfaced to a dead UI");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("a hung replay fetch that times out AFTER dispose does not surface onError", async () => {
|
|
464
|
+
// replay()'s catch mirrors the same class. Its success path guards on `this.#disposed || token !== opToken`;
|
|
465
|
+
// the error path must too, so a replay whose bounded transcript fetch times out after dispose() stays silent.
|
|
466
|
+
const r = rig();
|
|
467
|
+
const timers = new Map<number, () => void>();
|
|
468
|
+
let nextId = 0;
|
|
469
|
+
const env: SupplyCockpitEnv = {
|
|
470
|
+
...r.env,
|
|
471
|
+
setTimer: (run) => {
|
|
472
|
+
const id = nextId++;
|
|
473
|
+
timers.set(id, run);
|
|
474
|
+
return id;
|
|
475
|
+
},
|
|
476
|
+
clearTimer: (handle) => {
|
|
477
|
+
timers.delete(handle as number);
|
|
478
|
+
},
|
|
479
|
+
pastFetchTimeoutMs: 5000,
|
|
480
|
+
fetchTranscript: () => new Promise<TranscriptDataReport>(() => {}), // hangs forever
|
|
481
|
+
};
|
|
482
|
+
const cockpit = bootSupplyCockpit(env);
|
|
483
|
+
await cockpit.refresh(); // the list fetch resolves, clearing its bounded timer
|
|
484
|
+
await flush();
|
|
485
|
+
|
|
486
|
+
const replaying = cockpit.replay("job:past"); // starts the hung replay fetch + its bounded timeout
|
|
487
|
+
await flush();
|
|
488
|
+
cockpit.dispose(); // tear down while the replay fetch is still outstanding
|
|
489
|
+
for (const run of [...timers.values()]) run(); // fire the bounded timeout -> the wait rejects
|
|
490
|
+
await replaying;
|
|
491
|
+
await flush();
|
|
492
|
+
|
|
493
|
+
assert.deepEqual(r.errors, [], "a post-dispose replay timeout is swallowed, not surfaced to a dead UI");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("constructing with fetchTranscripts but no fetchTranscript fails fast (replay would silently no-op)", () => {
|
|
497
|
+
const r = rig(false);
|
|
498
|
+
assert.throws(
|
|
499
|
+
() => bootSupplyCockpit({ ...r.env, fetchTranscripts: () => Promise.resolve(transcripts) }),
|
|
500
|
+
/fetchTranscripts and fetchTranscript must be provided together/,
|
|
501
|
+
"a past list source without a replay source is a half-wired env",
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("constructing with fetchTranscript but no fetchTranscripts fails fast (unreachable replay source)", () => {
|
|
506
|
+
const r = rig(false);
|
|
507
|
+
assert.throws(
|
|
508
|
+
() => bootSupplyCockpit({ ...r.env, fetchTranscript: () => Promise.resolve(transcriptData) }),
|
|
509
|
+
/fetchTranscripts and fetchTranscript must be provided together/,
|
|
510
|
+
"a replay source with no past list to launch it from is half-wired too",
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("constructing with both transcript sources (or neither) is accepted", () => {
|
|
515
|
+
const both = rig(true);
|
|
516
|
+
const neither = rig(false);
|
|
517
|
+
assert.doesNotThrow(() => bootSupplyCockpit(both.env), "both sources together is the wired-up case");
|
|
518
|
+
assert.doesNotThrow(() => bootSupplyCockpit(neither.env), "neither source is the transcripts-off case");
|
|
519
|
+
});
|