@nanobpm/nano-workforce 0.69.0 → 0.70.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/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/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/resources/processes/convergence-loop.bpmn +1 -1
- package/resources/processes/feature.bpmn +1 -1
- package/resources/processes/merge-loop.bpmn +2 -2
- package/resources/processes/plan-fanout.bpmn +4 -4
- package/resources/processes/retro.bpmn +1 -1
|
@@ -277,3 +277,37 @@ test("injecting setTimer without clearTimer (or vice versa) fails fast", () => {
|
|
|
277
277
|
/setTimer and clearTimer must be provided together/,
|
|
278
278
|
);
|
|
279
279
|
});
|
|
280
|
+
|
|
281
|
+
test("a drill whose terminal build throws resets the panel to idle instead of leaving a stale live indicator", async () => {
|
|
282
|
+
const r = rig();
|
|
283
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
284
|
+
await cockpit.refresh();
|
|
285
|
+
|
|
286
|
+
// First drill succeeds: the panel is live on wk-a.
|
|
287
|
+
cockpit.drill("wk-a");
|
|
288
|
+
assert.equal(cockpit.currentMode, "live");
|
|
289
|
+
assert.equal(cockpit.currentStream, "wk-a");
|
|
290
|
+
const disposesBefore = r.terminalDisposes;
|
|
291
|
+
|
|
292
|
+
// The next drill tears down the live wk-a terminal up-front, then fails to build the new one.
|
|
293
|
+
const baseCreate = r.env.createTerminal;
|
|
294
|
+
r.env.createTerminal = () => {
|
|
295
|
+
throw new Error("createTerminal boom");
|
|
296
|
+
};
|
|
297
|
+
cockpit.drill("wk-b");
|
|
298
|
+
r.env.createTerminal = baseCreate;
|
|
299
|
+
|
|
300
|
+
// The failure is surfaced and the prior live terminal was disposed by the up-front teardown.
|
|
301
|
+
assert.equal(r.errors.length, 1, "the build failure is surfaced to onError");
|
|
302
|
+
assert.equal(r.terminalDisposes, disposesBefore + 1, "the prior live terminal was torn down");
|
|
303
|
+
|
|
304
|
+
// The defect: the panel must NOT keep showing a stale "live wk-a" backed by a terminal that is
|
|
305
|
+
// gone — the region resets to idle, symmetric with replay() which clears mode up-front.
|
|
306
|
+
assert.equal(cockpit.currentMode, undefined, "mode reset to idle after a failed drill");
|
|
307
|
+
assert.equal(cockpit.currentStream, undefined, "stream cleared after a failed drill");
|
|
308
|
+
|
|
309
|
+
// And the region recovers: a subsequent successful drill mounts a fresh live terminal.
|
|
310
|
+
cockpit.drill("wk-a");
|
|
311
|
+
assert.equal(cockpit.currentMode, "live");
|
|
312
|
+
assert.equal(cockpit.currentStream, "wk-a");
|
|
313
|
+
});
|
|
@@ -31,10 +31,16 @@ import {
|
|
|
31
31
|
import { renderSupply } from "./supply-render.ts";
|
|
32
32
|
import type { SupplyReport } from "./supply-view.ts";
|
|
33
33
|
import { supplyView } from "./supply-view.ts";
|
|
34
|
+
import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts";
|
|
35
|
+
import type { TranscriptListReport } from "./transcript-view.ts";
|
|
36
|
+
import { transcriptsView } from "./transcript-view.ts";
|
|
34
37
|
|
|
35
38
|
/** Mounts a terminal into `host` and returns the sink relay output is written to. */
|
|
36
39
|
export type CreateTerminal = (host: ElementLike) => TerminalSink;
|
|
37
40
|
|
|
41
|
+
/** The terminal region's playback mode: a LIVE relay stream vs a REPLAYED (static) stored transcript. */
|
|
42
|
+
export type TerminalMode = "live" | "replay";
|
|
43
|
+
|
|
38
44
|
/** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
|
|
39
45
|
export type TimerHandle = unknown;
|
|
40
46
|
|
|
@@ -45,6 +51,16 @@ export interface SupplyCockpitEnv {
|
|
|
45
51
|
readonly doc: DocumentLike;
|
|
46
52
|
/** Fetches the latest SUPPLY report (e.g. over HTTP from the app's `/agentic/supply` endpoint). */
|
|
47
53
|
readonly fetchSupply: () => Promise<SupplyReport>;
|
|
54
|
+
/**
|
|
55
|
+
* Fetches the captured-session list (`GET /agentic/transcripts`) for the "past sessions" history.
|
|
56
|
+
* Optional: when omitted the past-sessions panel is not rendered (live-only cockpit).
|
|
57
|
+
*/
|
|
58
|
+
readonly fetchTranscripts?: () => Promise<TranscriptListReport>;
|
|
59
|
+
/**
|
|
60
|
+
* Fetches a stored transcript's bytes (`GET /agentic/transcripts/{stream}`) for static replay.
|
|
61
|
+
* Required for the "past sessions" replay to work; must be provided together with {@link fetchTranscripts}.
|
|
62
|
+
*/
|
|
63
|
+
readonly fetchTranscript?: (stream: string, from?: number) => Promise<TranscriptDataReport>;
|
|
48
64
|
/** Opens a socket to the app relay channel (one per drill-in connection). */
|
|
49
65
|
readonly connectRelay: SocketFactory;
|
|
50
66
|
/** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
|
|
@@ -61,6 +77,12 @@ export interface SupplyCockpitEnv {
|
|
|
61
77
|
readonly credit?: number;
|
|
62
78
|
/** A live worker idle longer than this (ms) grades `stale`. Default 15000. */
|
|
63
79
|
readonly staleAfterMs?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Upper bound (ms) on a single "past sessions" transcripts fetch. Default 15000. `#refreshPast` is
|
|
82
|
+
* single-flight, so a fetch that HANGS (never settles) would otherwise wedge the past panel forever;
|
|
83
|
+
* this timeout guarantees the wait settles so the flag clears and the next poll retries.
|
|
84
|
+
*/
|
|
85
|
+
readonly pastFetchTimeoutMs?: number;
|
|
64
86
|
/** Notified of a fetch/render/relay error (the poll keeps going). */
|
|
65
87
|
readonly onError?: (err: unknown) => void;
|
|
66
88
|
}
|
|
@@ -73,15 +95,20 @@ export interface SupplyCockpitHandle {
|
|
|
73
95
|
start(): void;
|
|
74
96
|
/** Stop the poll loop (leaves the last render in place). */
|
|
75
97
|
stop(): void;
|
|
76
|
-
/** Drill into a worker's relay stream, opening a resumable
|
|
98
|
+
/** Drill into a worker's relay stream, opening a resumable LIVE terminal. */
|
|
77
99
|
drill(stream: string): void;
|
|
78
|
-
/**
|
|
100
|
+
/** Replay a captured past session's stored transcript statically into the terminal (no live worker). */
|
|
101
|
+
replay(stream: string): Promise<void>;
|
|
102
|
+
/** The stream currently drilled into or replayed, if any. */
|
|
79
103
|
readonly currentStream: string | undefined;
|
|
104
|
+
/** Whether the terminal is showing a LIVE stream or a REPLAYED transcript (undefined when idle). */
|
|
105
|
+
readonly currentMode: TerminalMode | undefined;
|
|
80
106
|
/** Stop everything and release the terminal connection. */
|
|
81
107
|
dispose(): void;
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
const DEFAULT_REFRESH_MS = 2000;
|
|
111
|
+
const DEFAULT_PAST_FETCH_TIMEOUT_MS = 15000;
|
|
85
112
|
|
|
86
113
|
function isPosInt(value: number): boolean {
|
|
87
114
|
return Number.isSafeInteger(value) && value > 0;
|
|
@@ -95,8 +122,12 @@ interface Drill {
|
|
|
95
122
|
class SupplyCockpit implements SupplyCockpitHandle {
|
|
96
123
|
readonly #env: SupplyCockpitEnv;
|
|
97
124
|
readonly #listRegion: ElementLike;
|
|
125
|
+
readonly #pastRegion: ElementLike | undefined;
|
|
98
126
|
readonly #terminalHost: ElementLike;
|
|
127
|
+
readonly #terminalTitle: ElementLike;
|
|
128
|
+
readonly #terminalPanel: ElementLike;
|
|
99
129
|
readonly #refreshMs: number;
|
|
130
|
+
readonly #pastFetchTimeoutMs: number;
|
|
100
131
|
readonly #setTimer: (run: () => void, ms: number) => TimerHandle;
|
|
101
132
|
readonly #clearTimer: (handle: TimerHandle) => void;
|
|
102
133
|
readonly #timeouts = new Map<number, ReturnType<typeof setTimeout>>();
|
|
@@ -108,6 +139,19 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
108
139
|
// The currently mounted terminal, tracked so switching streams (and dispose) tears down the prior
|
|
109
140
|
// xterm instance instead of leaking it + its listeners.
|
|
110
141
|
#terminal: TerminalSink | undefined;
|
|
142
|
+
// The terminal region's current playback: a LIVE relay stream or a REPLAYED stored transcript, and
|
|
143
|
+
// the stream it is showing — so the past-sessions list can highlight the active replay and the
|
|
144
|
+
// panel title can distinguish live from replayed.
|
|
145
|
+
#mode: TerminalMode | undefined;
|
|
146
|
+
#shownStream: string | undefined;
|
|
147
|
+
// Bumped by every drill()/replay()/dispose() that takes over the terminal region. replay() is async
|
|
148
|
+
// (it awaits a transcript fetch); capturing this token before the await and re-checking it after lets
|
|
149
|
+
// a slow replay drop its result when a newer drill/replay has since claimed the terminal — so a
|
|
150
|
+
// late-resolving stale fetch can never clobber a newer selection (or leak the newer drill's client).
|
|
151
|
+
#opToken = 0;
|
|
152
|
+
// True while a #refreshPast() fetch is in flight, so the supply poll never stacks past-fetches and a
|
|
153
|
+
// hung transcripts endpoint can't accumulate pending calls.
|
|
154
|
+
#pastRefreshing = false;
|
|
111
155
|
// Bumped by every start()/stop() so an in-flight #tick() from a previous start cycle can't
|
|
112
156
|
// reschedule after a stop→start race and leave two overlapping poll chains running.
|
|
113
157
|
#generation = 0;
|
|
@@ -121,23 +165,38 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
121
165
|
if (!isPosInt(this.#refreshMs)) {
|
|
122
166
|
throw new RangeError(`SupplyCockpitEnv.refreshMs must be a positive safe integer, got ${this.#refreshMs}`);
|
|
123
167
|
}
|
|
168
|
+
this.#pastFetchTimeoutMs = env.pastFetchTimeoutMs ?? DEFAULT_PAST_FETCH_TIMEOUT_MS;
|
|
169
|
+
if (!isPosInt(this.#pastFetchTimeoutMs)) {
|
|
170
|
+
throw new RangeError(
|
|
171
|
+
`SupplyCockpitEnv.pastFetchTimeoutMs must be a positive safe integer, got ${this.#pastFetchTimeoutMs}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
124
174
|
// setTimer/clearTimer are a matched pair: a caller-supplied setTimer returns opaque handles the
|
|
125
175
|
// default clearTimer (which only understands the internal numeric-handle Map) cannot cancel,
|
|
126
176
|
// leaving an un-stoppable poll loop. Fail fast rather than silently accept one without the other.
|
|
127
177
|
if ((env.setTimer === undefined) !== (env.clearTimer === undefined)) {
|
|
128
178
|
throw new Error("SupplyCockpitEnv.setTimer and clearTimer must be provided together (or neither)");
|
|
129
179
|
}
|
|
180
|
+
// fetchTranscripts (the past-sessions LIST source) and fetchTranscript (the per-session REPLAY source)
|
|
181
|
+
// are a matched pair: the past panel is rendered whenever the list source is present, but its replay
|
|
182
|
+
// buttons route through replay(), which no-ops without the replay source — so a list source without a
|
|
183
|
+
// replay source surfaces buttons that silently do nothing, and a replay source without a list source
|
|
184
|
+
// is an unreachable capability. Require both together (or neither) so a half-wired env fails loudly.
|
|
185
|
+
if ((env.fetchTranscripts === undefined) !== (env.fetchTranscript === undefined)) {
|
|
186
|
+
throw new Error("SupplyCockpitEnv.fetchTranscripts and fetchTranscript must be provided together (or neither)");
|
|
187
|
+
}
|
|
130
188
|
this.#setTimer =
|
|
131
189
|
env.setTimer ??
|
|
132
190
|
((run, ms) => {
|
|
133
191
|
const id = this.#nextTimerId++;
|
|
134
|
-
|
|
135
|
-
id
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
);
|
|
192
|
+
const timeout = setTimeout(() => {
|
|
193
|
+
this.#timeouts.delete(id);
|
|
194
|
+
run();
|
|
195
|
+
}, ms);
|
|
196
|
+
// A pending default timer (poll delay or the past-fetch timeout below) must never keep the
|
|
197
|
+
// process alive on its own — a no-op in the browser, where timer handles have no `unref`.
|
|
198
|
+
timeout.unref?.();
|
|
199
|
+
this.#timeouts.set(id, timeout);
|
|
141
200
|
return id;
|
|
142
201
|
});
|
|
143
202
|
this.#clearTimer =
|
|
@@ -151,30 +210,52 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
151
210
|
}
|
|
152
211
|
});
|
|
153
212
|
|
|
154
|
-
// Build the stable skeleton once: a volatile list region the poll re-renders,
|
|
155
|
-
//
|
|
213
|
+
// Build the stable skeleton once: a volatile list region the poll re-renders, an optional volatile
|
|
214
|
+
// "past sessions" region (rendered only when the transcript read endpoints are wired), and a
|
|
215
|
+
// PERSISTENT terminal region a refresh never touches (so a drilled-in/replayed terminal survives).
|
|
156
216
|
env.host.replaceChildren();
|
|
157
217
|
const shell = env.doc.createElement("div");
|
|
158
218
|
shell.className = "cockpit-shell";
|
|
159
219
|
this.#listRegion = env.doc.createElement("div");
|
|
160
220
|
this.#listRegion.className = "cockpit-supply-region";
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
terminalPanel.
|
|
221
|
+
// The past-sessions history list only exists when a transcript list source is injected.
|
|
222
|
+
if (env.fetchTranscripts !== undefined) {
|
|
223
|
+
this.#pastRegion = env.doc.createElement("div");
|
|
224
|
+
this.#pastRegion.className = "cockpit-past-region";
|
|
225
|
+
}
|
|
226
|
+
this.#terminalPanel = env.doc.createElement("section");
|
|
227
|
+
this.#terminalPanel.className = "cockpit-terminal";
|
|
228
|
+
this.#terminalPanel.setAttribute("data-terminal-mode", "idle");
|
|
229
|
+
this.#terminalTitle = env.doc.createElement("h2");
|
|
230
|
+
this.#terminalTitle.className = "cockpit-panel-title";
|
|
231
|
+
this.#terminalTitle.textContent = "Worker terminal";
|
|
232
|
+
this.#terminalPanel.appendChild(this.#terminalTitle);
|
|
167
233
|
this.#terminalHost = env.doc.createElement("div");
|
|
168
234
|
this.#terminalHost.className = "cockpit-terminal-host";
|
|
169
235
|
this.#terminalHost.setAttribute("data-terminal", "host");
|
|
170
|
-
terminalPanel.appendChild(this.#terminalHost);
|
|
236
|
+
this.#terminalPanel.appendChild(this.#terminalHost);
|
|
171
237
|
shell.appendChild(this.#listRegion);
|
|
172
|
-
shell.appendChild(
|
|
238
|
+
if (this.#pastRegion !== undefined) shell.appendChild(this.#pastRegion);
|
|
239
|
+
shell.appendChild(this.#terminalPanel);
|
|
173
240
|
env.host.appendChild(shell);
|
|
174
241
|
}
|
|
175
242
|
|
|
176
243
|
get currentStream(): string | undefined {
|
|
177
|
-
return this.#
|
|
244
|
+
return this.#shownStream;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
get currentMode(): TerminalMode | undefined {
|
|
248
|
+
return this.#mode;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Reflect the terminal region's playback mode on the panel (title + `data-terminal-mode`). */
|
|
252
|
+
#setMode(mode: TerminalMode | undefined, stream: string | undefined): void {
|
|
253
|
+
this.#mode = mode;
|
|
254
|
+
this.#shownStream = stream;
|
|
255
|
+
this.#terminalPanel.setAttribute("data-terminal-mode", mode ?? "idle");
|
|
256
|
+
if (mode === "live") this.#terminalTitle.textContent = "Worker terminal — live";
|
|
257
|
+
else if (mode === "replay") this.#terminalTitle.textContent = "Worker terminal — replay (past session)";
|
|
258
|
+
else this.#terminalTitle.textContent = "Worker terminal";
|
|
178
259
|
}
|
|
179
260
|
|
|
180
261
|
async refresh(): Promise<void> {
|
|
@@ -183,6 +264,7 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
183
264
|
try {
|
|
184
265
|
report = await this.#env.fetchSupply();
|
|
185
266
|
} catch (err) {
|
|
267
|
+
if (this.#disposed) return;
|
|
186
268
|
this.#env.onError?.(err);
|
|
187
269
|
return;
|
|
188
270
|
}
|
|
@@ -194,6 +276,93 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
194
276
|
} catch (err) {
|
|
195
277
|
this.#env.onError?.(err);
|
|
196
278
|
}
|
|
279
|
+
// Fire-and-forget: the "past sessions" refresh must never gate the supply poll's next tick. A
|
|
280
|
+
// transcripts endpoint that hangs (not just rejects) would otherwise stall #refresh() forever and
|
|
281
|
+
// wedge the live worker list. #refreshPast is single-flight, so a slow fetch can't pile up either.
|
|
282
|
+
void this.#refreshPast();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Fetch + render the "past sessions" history list, when a transcript source is wired. Independent
|
|
286
|
+
* of the supply fetch: a transcript-endpoint fault (or hang) never blocks the live worker list. */
|
|
287
|
+
async #refreshPast(): Promise<void> {
|
|
288
|
+
const fetchTranscripts = this.#env.fetchTranscripts;
|
|
289
|
+
if (fetchTranscripts === undefined || this.#pastRegion === undefined) return;
|
|
290
|
+
// Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
|
|
291
|
+
// so the poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.
|
|
292
|
+
if (this.#pastRefreshing) return;
|
|
293
|
+
this.#pastRefreshing = true;
|
|
294
|
+
try {
|
|
295
|
+
let report: TranscriptListReport;
|
|
296
|
+
try {
|
|
297
|
+
report = await this.#bounded(fetchTranscripts, "transcripts");
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (this.#disposed) return;
|
|
300
|
+
this.#env.onError?.(err);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (this.#disposed || this.#pastRegion === undefined) return;
|
|
304
|
+
try {
|
|
305
|
+
renderTranscripts(this.#pastRegion, this.#env.doc, transcriptsView(report), {
|
|
306
|
+
onReplay: (stream) => void this.replay(stream),
|
|
307
|
+
...(this.#mode === "replay" && this.#shownStream !== undefined ? { activeStream: this.#shownStream } : {}),
|
|
308
|
+
});
|
|
309
|
+
} catch (err) {
|
|
310
|
+
this.#env.onError?.(err);
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
this.#pastRefreshing = false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Race an injected fetch against a timeout so the returned promise ALWAYS settles, even if the fetch
|
|
319
|
+
* HANGS (never settles, not merely rejects). Both single-flight callers below — the past-sessions list
|
|
320
|
+
* refresh and a past-session {@link replay} — depend on this: a hung list fetch would leave
|
|
321
|
+
* `#pastRefreshing` stuck `true` forever (permanently disabling the past panel), and a hung replay fetch
|
|
322
|
+
* would leave `replay()` pending forever with the terminal wedged out of live mode. Racing the fetch
|
|
323
|
+
* against a timeout guarantees the wait settles (here, rejects), so the caller's `finally`/`catch` runs
|
|
324
|
+
* and the next poll can retry. A hung fetch that resolves late is ignored (the `settled` latch drops it).
|
|
325
|
+
*/
|
|
326
|
+
#bounded<T>(fetch: () => Promise<T>, what: string): Promise<T> {
|
|
327
|
+
return new Promise<T>((resolve, reject) => {
|
|
328
|
+
let settled = false;
|
|
329
|
+
const handle = this.#setTimer(() => {
|
|
330
|
+
if (settled) return;
|
|
331
|
+
settled = true;
|
|
332
|
+
// Clear our own handle on the timeout arm too, symmetric with the fetch arms below: a
|
|
333
|
+
// caller-supplied clearTimer may reclaim a handle a fired timer still holds, and clearing here
|
|
334
|
+
// guards against a custom scheduler re-invoking the callback (the settled latch is belt-and-braces).
|
|
335
|
+
this.#clearTimer(handle);
|
|
336
|
+
reject(new Error(`${what} fetch timed out after ${this.#pastFetchTimeoutMs}ms`));
|
|
337
|
+
}, this.#pastFetchTimeoutMs);
|
|
338
|
+
// Invoke the fetch inside try/catch so a SYNCHRONOUS throw (not a rejected promise) is handled on the
|
|
339
|
+
// same arms as an async rejection: clear our timer and reject once. Without this, a sync throw escapes
|
|
340
|
+
// the executor (rejecting the promise) but leaves the timeout handle scheduled — a leak that fires
|
|
341
|
+
// (and, under a custom scheduler, could re-fire) long after the wait has already settled.
|
|
342
|
+
let pending: Promise<T>;
|
|
343
|
+
try {
|
|
344
|
+
pending = fetch();
|
|
345
|
+
} catch (err) {
|
|
346
|
+
settled = true;
|
|
347
|
+
this.#clearTimer(handle);
|
|
348
|
+
reject(err);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
pending.then(
|
|
352
|
+
(report) => {
|
|
353
|
+
if (settled) return;
|
|
354
|
+
settled = true;
|
|
355
|
+
this.#clearTimer(handle);
|
|
356
|
+
resolve(report);
|
|
357
|
+
},
|
|
358
|
+
(err) => {
|
|
359
|
+
if (settled) return;
|
|
360
|
+
settled = true;
|
|
361
|
+
this.#clearTimer(handle);
|
|
362
|
+
reject(err);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
});
|
|
197
366
|
}
|
|
198
367
|
|
|
199
368
|
start(): void {
|
|
@@ -225,7 +394,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
225
394
|
|
|
226
395
|
drill(stream: string): void {
|
|
227
396
|
if (this.#disposed) return;
|
|
228
|
-
if (this.#drill?.stream === stream) return;
|
|
397
|
+
if (this.#mode === "live" && this.#drill?.stream === stream) return;
|
|
398
|
+
// Claim the terminal region: bump the op token so any in-flight replay (whose fetch has not yet
|
|
399
|
+
// resolved) drops its result instead of overwriting this live drill once it lands.
|
|
400
|
+
this.#opToken++;
|
|
229
401
|
// Close and drop the prior drill up-front so a synchronous failure while building the new one
|
|
230
402
|
// (createTerminal, an invalid TerminalSession credit, or connect throwing) can't leave #drill
|
|
231
403
|
// pointing at an already-closed client. #drill is re-set only once the new client is fully wired.
|
|
@@ -260,6 +432,67 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
260
432
|
});
|
|
261
433
|
client.open();
|
|
262
434
|
this.#drill = { stream, client };
|
|
435
|
+
this.#setMode("live", stream);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
// Building the new terminal failed AFTER the prior drill + terminal were already torn down
|
|
438
|
+
// above. Leaving #mode/#shownStream at their prior value would keep the panel showing a stale
|
|
439
|
+
// "live"/"replay" indicator backing a terminal that no longer exists, and a partially-built
|
|
440
|
+
// #terminal (createTerminal returned before a later step threw) would leak. Reset the region to
|
|
441
|
+
// idle — symmetric with replay(), which clears mode up-front — before surfacing the error.
|
|
442
|
+
this.#drill?.client.close();
|
|
443
|
+
this.#drill = undefined;
|
|
444
|
+
this.#terminal?.dispose?.();
|
|
445
|
+
this.#terminal = undefined;
|
|
446
|
+
this.#setMode(undefined, undefined);
|
|
447
|
+
this.#env.onError?.(err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Replay a captured PAST session's stored transcript into the terminal region — static playback of a
|
|
453
|
+
* closed stream with NO live worker and NO relay connection. Tears down any live drill first, fetches
|
|
454
|
+
* the transcript's bytes, and feeds them through the SAME resume-from-offset {@link TerminalSession}
|
|
455
|
+
* renderer a live stream uses, so the exited agent's terminal renders faithfully. The panel is marked
|
|
456
|
+
* `replay` so the operator plainly sees it is a past session, not a live one.
|
|
457
|
+
*/
|
|
458
|
+
async replay(stream: string): Promise<void> {
|
|
459
|
+
if (this.#disposed) return;
|
|
460
|
+
const fetchTranscript = this.#env.fetchTranscript;
|
|
461
|
+
if (fetchTranscript === undefined) return;
|
|
462
|
+
// Claim the terminal region under a fresh op token, captured for the post-fetch re-check below.
|
|
463
|
+
const token = ++this.#opToken;
|
|
464
|
+
// Drop any live drill and the prior terminal before fetching so a replay never runs alongside a
|
|
465
|
+
// live stream in the same region.
|
|
466
|
+
this.#drill?.client.close();
|
|
467
|
+
this.#drill = undefined;
|
|
468
|
+
this.#terminal?.dispose?.();
|
|
469
|
+
this.#terminal = undefined;
|
|
470
|
+
this.#setMode(undefined, undefined);
|
|
471
|
+
|
|
472
|
+
let data: TranscriptDataReport;
|
|
473
|
+
try {
|
|
474
|
+
// Bound the transcript fetch: a hung endpoint must not leave replay() pending forever with the
|
|
475
|
+
// terminal wedged out of live mode. The wait always settles, so this catch runs and mode stays idle.
|
|
476
|
+
data = await this.#bounded(() => fetchTranscript(stream), "transcript");
|
|
477
|
+
} catch (err) {
|
|
478
|
+
// Mirror the success path's guard: a replay superseded by a newer op (or a disposed cockpit) must not
|
|
479
|
+
// surface its late timeout/rejection — the terminal region no longer belongs to this stale replay.
|
|
480
|
+
if (this.#disposed || token !== this.#opToken) return;
|
|
481
|
+
this.#env.onError?.(err);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// A newer drill()/replay() (or dispose()) claimed the terminal while this fetch was outstanding —
|
|
485
|
+
// drop this stale result rather than clobber the newer selection with an out-of-date replay.
|
|
486
|
+
if (this.#disposed || token !== this.#opToken) return;
|
|
487
|
+
try {
|
|
488
|
+
this.#terminalHost.replaceChildren();
|
|
489
|
+
const sink = this.#env.createTerminal(this.#terminalHost);
|
|
490
|
+
this.#terminal = sink;
|
|
491
|
+
const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from });
|
|
492
|
+
replayTranscript(session, data);
|
|
493
|
+
this.#setMode("replay", stream);
|
|
494
|
+
// Re-render the past list so the just-selected session shows as active (best-effort).
|
|
495
|
+
void this.#refreshPast();
|
|
263
496
|
} catch (err) {
|
|
264
497
|
this.#env.onError?.(err);
|
|
265
498
|
}
|
|
@@ -268,11 +501,13 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
268
501
|
dispose(): void {
|
|
269
502
|
if (this.#disposed) return;
|
|
270
503
|
this.#disposed = true;
|
|
504
|
+
this.#opToken++;
|
|
271
505
|
this.stop();
|
|
272
506
|
this.#drill?.client.close();
|
|
273
507
|
this.#drill = undefined;
|
|
274
508
|
this.#terminal?.dispose?.();
|
|
275
509
|
this.#terminal = undefined;
|
|
510
|
+
this.#setMode(undefined, undefined);
|
|
276
511
|
}
|
|
277
512
|
}
|
|
278
513
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Unit tests for the cockpit "past sessions" renderer + static-replay helper (H3 read path / #222).
|
|
2
|
+
//
|
|
3
|
+
// Covers: the history list DOM (rows keyed by stream/status, replay buttons, empty state, active
|
|
4
|
+
// highlight), and that replayTranscript feeds a real resume-from-offset TerminalSession so a closed
|
|
5
|
+
// stream's stored bytes render faithfully through the SAME renderer a live stream uses.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { TerminalSession } from "@nanobpm/agentic/cockpit";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
9
|
+
import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
|
|
10
|
+
import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts";
|
|
11
|
+
import { transcriptsView } from "./transcript-view.ts";
|
|
12
|
+
|
|
13
|
+
const doc = new FakeDocument();
|
|
14
|
+
|
|
15
|
+
function view() {
|
|
16
|
+
return transcriptsView({
|
|
17
|
+
count: 2,
|
|
18
|
+
retentionMs: 86_400_000,
|
|
19
|
+
transcripts: [
|
|
20
|
+
{ stream: "job:1", lifecycle: "ephemeral", status: "completed", createdAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:05:00Z", nextOffset: 1, byteLength: 5, chunkCount: 1, jobKey: "1", planKey: "o/r#1" },
|
|
21
|
+
{ stream: "job:2", lifecycle: "ephemeral", status: "open", createdAt: "2026-01-02T00:00:00Z", nextOffset: 1, byteLength: 5, chunkCount: 1, jobKey: "2" },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("renders a past-sessions row per captured session, keyed by stream + status", () => {
|
|
27
|
+
const host = new FakeElement("div");
|
|
28
|
+
renderTranscripts(host, doc, view());
|
|
29
|
+
assertEquals(host.byData("stream", "job:1").length >= 1, true);
|
|
30
|
+
assertEquals(host.byData("stream", "job:2").length >= 1, true);
|
|
31
|
+
assertEquals(host.byData("session-count", "2").length, 1);
|
|
32
|
+
const row1 = host.byData("stream", "job:1").find((n) => n.className.includes("cockpit-past-session"));
|
|
33
|
+
assertEquals(row1?.getAttribute("data-status"), "completed");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("clicking a session's replay button calls onReplay with its stream", () => {
|
|
37
|
+
const host = new FakeElement("div");
|
|
38
|
+
const replayed: string[] = [];
|
|
39
|
+
renderTranscripts(host, doc, view(), { onReplay: (s) => replayed.push(s) });
|
|
40
|
+
const button = host.byClass("cockpit-past-replay").find((b) => b.getAttribute("data-stream") === "job:2");
|
|
41
|
+
button?.dispatch("click");
|
|
42
|
+
assertEquals(replayed, ["job:2"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("highlights the active (currently replayed) session", () => {
|
|
46
|
+
const host = new FakeElement("div");
|
|
47
|
+
renderTranscripts(host, doc, view(), { activeStream: "job:1" });
|
|
48
|
+
const active = host.byData("active", "true");
|
|
49
|
+
assertEquals(active.length, 1);
|
|
50
|
+
assertEquals(active[0]?.getAttribute("data-stream"), "job:1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("renders an empty state when there are no captured sessions", () => {
|
|
54
|
+
const host = new FakeElement("div");
|
|
55
|
+
renderTranscripts(host, doc, transcriptsView({ count: 0, transcripts: [] }));
|
|
56
|
+
assertEquals(host.byData("empty", "true").length, 1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("replayTranscript feeds a real TerminalSession so stored bytes render in offset order", () => {
|
|
60
|
+
const writes: string[] = [];
|
|
61
|
+
const gaps: number[] = [];
|
|
62
|
+
const data: TranscriptDataReport = {
|
|
63
|
+
stream: "job:9",
|
|
64
|
+
from: 0,
|
|
65
|
+
gap: false,
|
|
66
|
+
nextOffset: 3,
|
|
67
|
+
entries: [
|
|
68
|
+
{ offset: 0, chunk: "aa" },
|
|
69
|
+
{ offset: 1, chunk: "bb" },
|
|
70
|
+
{ offset: 2, chunk: "cc" },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
const session = new TerminalSession({
|
|
74
|
+
stream: "job:9",
|
|
75
|
+
sink: { write: (c) => writes.push(c) },
|
|
76
|
+
send: () => {},
|
|
77
|
+
from: data.from,
|
|
78
|
+
onGap: () => gaps.push(1),
|
|
79
|
+
});
|
|
80
|
+
const written = replayTranscript(session, data);
|
|
81
|
+
assertEquals(written, 3);
|
|
82
|
+
assertEquals(writes, ["aa", "bb", "cc"]);
|
|
83
|
+
assertEquals(gaps.length, 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("replayTranscript resumes from a later offset and reports a retention gap", () => {
|
|
87
|
+
const writes: string[] = [];
|
|
88
|
+
const gaps: number[] = [];
|
|
89
|
+
const data: TranscriptDataReport = {
|
|
90
|
+
stream: "job:9",
|
|
91
|
+
from: 5,
|
|
92
|
+
gap: true,
|
|
93
|
+
nextOffset: 7,
|
|
94
|
+
entries: [
|
|
95
|
+
{ offset: 5, chunk: "ee" },
|
|
96
|
+
{ offset: 6, chunk: "ff" },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
const session = new TerminalSession({
|
|
100
|
+
stream: "job:9",
|
|
101
|
+
sink: { write: (c) => writes.push(c) },
|
|
102
|
+
send: () => {},
|
|
103
|
+
from: data.from,
|
|
104
|
+
onGap: () => gaps.push(1),
|
|
105
|
+
});
|
|
106
|
+
const written = replayTranscript(session, data);
|
|
107
|
+
assertEquals(written, 2);
|
|
108
|
+
assertEquals(writes, ["ee", "ff"]);
|
|
109
|
+
assertEquals(gaps.length, 1, "the retention gap in the fetched page is surfaced");
|
|
110
|
+
});
|