@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.
@@ -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 live terminal. */
98
+ /** Drill into a worker's relay stream, opening a resumable LIVE terminal. */
77
99
  drill(stream: string): void;
78
- /** The stream currently drilled into, if any. */
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
- this.#timeouts.set(
135
- id,
136
- setTimeout(() => {
137
- this.#timeouts.delete(id);
138
- run();
139
- }, ms),
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, and a PERSISTENT
155
- // terminal region a refresh never touches.
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
- 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);
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(terminalPanel);
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.#drill?.stream;
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
+ });