@nanobpm/nano-workforce 0.69.1 → 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.
@@ -0,0 +1,61 @@
1
+ // Unit tests for the cockpit "past sessions" view-model (H3 read path / #222).
2
+ //
3
+ // Pure projection: a transcript LIST report → the renderable history view. Covers labelling (process
4
+ // instance / plan → single label, jobKey/stream fallback), human byte/duration formatting, newest-first
5
+ // ordering, and the retention surfacing.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { humanBytes, humanDuration, type TranscriptListReport, transcriptsView } from "./transcript-view.ts";
9
+
10
+ test("humanBytes renders B / KB / MB compactly", () => {
11
+ assertEquals(humanBytes(0), "0 B");
12
+ assertEquals(humanBytes(512), "512 B");
13
+ assertEquals(humanBytes(2048), "2.0 KB");
14
+ assertEquals(humanBytes(5 * 1024 * 1024), "5.0 MB");
15
+ assertEquals(humanBytes(-1), "0 B");
16
+ });
17
+
18
+ test("humanDuration renders s / m / h / d, undefined for non-positive", () => {
19
+ assertEquals(humanDuration(45_000), "45s");
20
+ assertEquals(humanDuration(30 * 60_000), "30m");
21
+ assertEquals(humanDuration(24 * 60 * 60_000), "24h");
22
+ assertEquals(humanDuration(3 * 24 * 60 * 60_000), "3d");
23
+ assertEquals(humanDuration(undefined), undefined);
24
+ assertEquals(humanDuration(0), undefined);
25
+ });
26
+
27
+ test("projects sessions newest-first with a process/plan label and surfaces retention", () => {
28
+ const report: TranscriptListReport = {
29
+ count: 2,
30
+ retentionMs: 86_400_000,
31
+ transcripts: [
32
+ { stream: "job:1", lifecycle: "ephemeral", status: "completed", createdAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:05:00Z", nextOffset: 2, byteLength: 2048, chunkCount: 2, jobKey: "1", bpmnProcessId: "plan-fanout", processInstanceKey: "4612", planKey: "o/r#142" },
33
+ { stream: "job:2", lifecycle: "ephemeral", status: "completed", createdAt: "2026-01-02T00:00:00Z", completedAt: "2026-01-02T00:05:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, jobKey: "2" },
34
+ ],
35
+ };
36
+ const view = transcriptsView(report);
37
+ assertEquals(view.count, 2);
38
+ assertEquals(view.retention, "24h");
39
+ // Newest capturedAt (job:2, completed 01-02) first.
40
+ assertEquals(view.sessions[0]?.stream, "job:2");
41
+ assertEquals(view.sessions[0]?.label, "job 2", "no engine context → job-key label");
42
+ assertEquals(view.sessions[0]?.size, "10 B");
43
+ assertEquals(view.sessions[1]?.stream, "job:1");
44
+ assertEquals(view.sessions[1]?.label, "plan-fanout · inst 4612 · o/r#142");
45
+ assertEquals(view.sessions[1]?.size, "2.0 KB");
46
+ assertEquals(view.sessions[1]?.capturedAt, "2026-01-01T00:05:00Z");
47
+ });
48
+
49
+ test("falls back to the stream id when neither jobKey nor context is known, and uses createdAt when open", () => {
50
+ const report: TranscriptListReport = {
51
+ count: 1,
52
+ transcripts: [
53
+ { stream: "ctrl:x", lifecycle: "long-lived", status: "open", createdAt: "2026-01-01T00:00:00Z", nextOffset: 3, byteLength: 3, chunkCount: 3 },
54
+ ],
55
+ };
56
+ const view = transcriptsView(report);
57
+ assertEquals(view.sessions[0]?.label, "ctrl:x");
58
+ assertEquals(view.sessions[0]?.status, "open");
59
+ assertEquals(view.sessions[0]?.capturedAt, "2026-01-01T00:00:00Z", "open session uses createdAt");
60
+ assertEquals(view.retention, undefined);
61
+ });
@@ -0,0 +1,131 @@
1
+ // The cockpit "past sessions" view-model (ADR 0056, H3 read path / #222).
2
+ //
3
+ // A pure, deterministic projection of the app's transcript LIST report (`GET /agentic/transcripts`) —
4
+ // the durable transcripts an ephemeral agent flushed on job completion — onto the shape the cockpit's
5
+ // "past sessions" history list renders beside the LIVE supply list. Selecting a past session replays
6
+ // its stored transcript into the SAME persistent terminal region as a live drill-in (static playback
7
+ // of a closed stream), so the operator can review "what did that agent do" after it is gone.
8
+ //
9
+ // Like `./supply-view.ts` it is framework-free and side-effect-free: the same report always yields the
10
+ // same {@link TranscriptView}, so it renders identically embedded (App View) and standalone, and is
11
+ // unit-testable on Node with no browser.
12
+
13
+ /** One captured session as the app's transcript list reports it (mirrors `AgenticTranscript`). */
14
+ export interface TranscriptSummaryReport {
15
+ readonly stream: string;
16
+ readonly lifecycle: "ephemeral" | "long-lived";
17
+ readonly status: "open" | "completed";
18
+ readonly createdAt: string;
19
+ readonly completedAt?: string;
20
+ readonly firstOffset?: number;
21
+ readonly nextOffset: number;
22
+ readonly byteLength: number;
23
+ readonly chunkCount: number;
24
+ readonly jobKey?: string;
25
+ readonly processInstanceKey?: string;
26
+ readonly bpmnProcessId?: string;
27
+ readonly elementId?: string;
28
+ readonly planKey?: string;
29
+ }
30
+
31
+ /** The transcript list report the cockpit polls (mirrors `AgenticTranscriptList`). */
32
+ export interface TranscriptListReport {
33
+ readonly count: number;
34
+ readonly generatedAt?: string;
35
+ readonly retentionMs?: number;
36
+ readonly transcripts: readonly TranscriptSummaryReport[];
37
+ }
38
+
39
+ /** One past-session row in the renderable history view. */
40
+ export interface TranscriptView {
41
+ /** The relay stream id to replay (`job:<jobKey>` for a job stream). */
42
+ readonly stream: string;
43
+ /** A single stable human label for the session's process instance / plan (falls back to the stream). */
44
+ readonly label: string;
45
+ /** The Camunda-8 job key, when the stream encodes one. */
46
+ readonly jobKey?: string;
47
+ /** open (still capturing) vs completed (the ephemeral run flushed & sealed). */
48
+ readonly status: "open" | "completed";
49
+ /** Retention lifecycle. */
50
+ readonly lifecycle: "ephemeral" | "long-lived";
51
+ /** A human-readable captured size, e.g. "1.2 KB". */
52
+ readonly size: string;
53
+ /** The raw captured byte length. */
54
+ readonly byteLength: number;
55
+ /** When the session was captured — completedAt when sealed, else createdAt. */
56
+ readonly capturedAt: string;
57
+ }
58
+
59
+ /** The full renderable "past sessions" view. */
60
+ export interface TranscriptsView {
61
+ readonly sessions: readonly TranscriptView[];
62
+ readonly count: number;
63
+ /** A human-readable retention window (e.g. "24h"), or undefined when unknown. */
64
+ readonly retention?: string;
65
+ }
66
+
67
+ /** A single stable human label for a captured session's process instance / plan (empty parts dropped). */
68
+ function sessionLabel(t: TranscriptSummaryReport): string {
69
+ const parts: string[] = [];
70
+ if (t.bpmnProcessId !== undefined) parts.push(t.bpmnProcessId);
71
+ if (t.elementId !== undefined) parts.push(t.elementId);
72
+ if (t.processInstanceKey !== undefined) parts.push(`inst ${t.processInstanceKey}`);
73
+ if (t.planKey !== undefined) parts.push(t.planKey);
74
+ if (parts.length > 0) return parts.join(" \u00b7 ");
75
+ if (t.jobKey !== undefined) return `job ${t.jobKey}`;
76
+ return t.stream;
77
+ }
78
+
79
+ /** Render a byte count as a compact human string (B / KB / MB), stable and locale-free. */
80
+ export function humanBytes(bytes: number): string {
81
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
82
+ if (bytes < 1024) return `${bytes} B`;
83
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
84
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
85
+ }
86
+
87
+ /** Render a retention window (ms) as a compact human string (e.g. "24h", "30m", "45s"). */
88
+ export function humanDuration(ms: number | undefined): string | undefined {
89
+ if (ms === undefined || !Number.isFinite(ms) || ms <= 0) return undefined;
90
+ const s = Math.round(ms / 1000);
91
+ if (s < 60) return `${s}s`;
92
+ const m = Math.round(s / 60);
93
+ if (m < 60) return `${m}m`;
94
+ const h = Math.round(m / 60);
95
+ if (h < 48) return `${h}h`;
96
+ return `${Math.round(h / 24)}d`;
97
+ }
98
+
99
+ function sessionView(t: TranscriptSummaryReport): TranscriptView {
100
+ return {
101
+ stream: t.stream,
102
+ label: sessionLabel(t),
103
+ ...(t.jobKey !== undefined ? { jobKey: t.jobKey } : {}),
104
+ status: t.status,
105
+ lifecycle: t.lifecycle,
106
+ size: humanBytes(t.byteLength),
107
+ byteLength: t.byteLength,
108
+ capturedAt: t.completedAt ?? t.createdAt,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Derive the renderable "past sessions" view from the app's transcript list report.
114
+ *
115
+ * Pure and total: it re-sorts sessions newest-captured-first (stable on stream id) so the view is
116
+ * diff-friendly regardless of the report's incoming order; no input mutates and no I/O happens.
117
+ */
118
+ export function transcriptsView(report: TranscriptListReport): TranscriptsView {
119
+ const sessions = report.transcripts
120
+ .map(sessionView)
121
+ .sort((a, b) => {
122
+ const byTime = b.capturedAt.localeCompare(a.capturedAt);
123
+ return byTime !== 0 ? byTime : a.stream.localeCompare(b.stream);
124
+ });
125
+ const retention = humanDuration(report.retentionMs);
126
+ return {
127
+ sessions,
128
+ count: sessions.length,
129
+ ...(retention !== undefined ? { retention } : {}),
130
+ };
131
+ }
@@ -21,9 +21,11 @@ import { assert, assertEquals } from "#test-assert";
21
21
  import { noopLog } from "../../../test/log.ts";
22
22
  import {
23
23
  createRelayFamily,
24
+ currentRelayTranscriptService,
24
25
  family as relayFamily,
25
26
  RELAY_FAMILY_NAME,
26
27
  RelayTranscriptService,
28
+ sweepIntervalMs,
27
29
  } from "./relay.family.ts";
28
30
 
29
31
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -383,6 +385,107 @@ test("advisory resilience: a checkpoint flush failure keeps the long-lived strea
383
385
  service.teardown();
384
386
  });
385
387
 
388
+ test("mount installs the service singleton for the read path and teardown clears it (#222)", () => {
389
+ const registry = new ConnectionRegistry();
390
+ const ctx = {
391
+ hub: capturingHub() as never,
392
+ registry: registry as never,
393
+ transport: undefined as never,
394
+ data: { source: () => ({ db: memoryDb() }) } as never,
395
+ log: noopLog(),
396
+ };
397
+ const family = createRelayFamily();
398
+ assertEquals(currentRelayTranscriptService(), undefined, "no singleton before mount");
399
+ family.mount(ctx);
400
+ const service = currentRelayTranscriptService();
401
+ assert(service !== undefined, "mount installs the singleton the read endpoints source");
402
+ assert(service.store !== undefined, "the mounted service is persisted");
403
+ family.teardown?.();
404
+ assertEquals(currentRelayTranscriptService(), undefined, "teardown clears the singleton");
405
+ });
406
+
407
+ test("mount drives a retention sweep so completed-ephemeral transcripts are retired (#222)", () => {
408
+ const registry = new ConnectionRegistry();
409
+ // A tiny retention window + a clock we control: complete an ephemeral stream, advance past retention,
410
+ // then confirm the family's own sweep surface retires it (the periodic tick calls the same path).
411
+ let nowMs = 1_000_000;
412
+ const ctx = {
413
+ hub: capturingHub() as never,
414
+ registry: registry as never,
415
+ transport: undefined as never,
416
+ data: { source: () => ({ db: memoryDb() }) } as never,
417
+ log: noopLog(),
418
+ };
419
+ const family = createRelayFamily({ transcript: { ephemeralRetentionMs: 10, clock: { now: () => nowMs } } });
420
+ family.mount(ctx);
421
+ const service = currentRelayTranscriptService();
422
+ assert(service !== undefined);
423
+ service.store?.flush("job:9", { since: () => ({ entries: [{ offset: 0, chunk: "x" }] }), nextOffset: 1 }, "ephemeral");
424
+ assertEquals(service.transcriptOf("job:9")?.status, "completed");
425
+ nowMs += 1000; // advance well past the 10ms retention window
426
+ const retired = service.sweep();
427
+ assertEquals(retired, ["job:9"], "the completed-ephemeral transcript is retired past retention");
428
+ assertEquals(service.transcriptOf("job:9"), undefined);
429
+ family.teardown?.();
430
+ });
431
+
432
+ test("mount runs an eager retention sweep so a transcript already past retention from a previous run is retired on boot (#222)", () => {
433
+ const registry = new ConnectionRegistry();
434
+ // One durable db shared across two mounts models a process restart: the transcript table survives,
435
+ // so a completed-ephemeral transcript persisted before downtime is still present at the next boot.
436
+ const db = memoryDb();
437
+ let nowMs = 1_000_000;
438
+ const mkCtx = () => ({
439
+ hub: capturingHub() as never,
440
+ registry: registry as never,
441
+ transport: undefined as never,
442
+ data: { source: () => ({ db }) } as never,
443
+ log: noopLog(),
444
+ });
445
+
446
+ // First run: persist a completed-ephemeral transcript, then simulate downtime past its retention window.
447
+ const first = createRelayFamily({ transcript: { ephemeralRetentionMs: 10, clock: { now: () => nowMs } } });
448
+ first.mount(mkCtx());
449
+ const s1 = currentRelayTranscriptService();
450
+ assert(s1 !== undefined);
451
+ s1.store?.flush("job:stale", { since: () => ({ entries: [{ offset: 0, chunk: "x" }] }), nextOffset: 1 }, "ephemeral");
452
+ assertEquals(s1.transcriptOf("job:stale")?.status, "completed");
453
+ first.teardown?.();
454
+ nowMs += 1000; // downtime elapses well past the 10ms retention window
455
+
456
+ // Second run (restart) over the SAME durable db: the eager mount sweep must retire the already-expired
457
+ // transcript immediately — without waiting for the first periodic tick and without an explicit sweep().
458
+ const second = createRelayFamily({ transcript: { ephemeralRetentionMs: 10, clock: { now: () => nowMs } } });
459
+ second.mount(mkCtx());
460
+ const s2 = currentRelayTranscriptService();
461
+ assert(s2 !== undefined);
462
+ assertEquals(
463
+ s2.transcriptOf("job:stale"),
464
+ undefined,
465
+ "the eager mount sweep retires a transcript already past retention from a previous run",
466
+ );
467
+ second.teardown?.();
468
+ });
469
+
470
+ test("sweep cadence: a fraction of the retention window, floored at 1ms and capped at the Node timer max", () => {
471
+ // A normal retention window derives a quarter-window cadence.
472
+ assertEquals(sweepIntervalMs(1000), 250);
473
+ // A tiny/zero window still floors at a live 1ms tick rather than 0.
474
+ assertEquals(sweepIntervalMs(1), 1);
475
+ assertEquals(sweepIntervalMs(0), 1);
476
+ // A very large window (~1 year) would derive a >2^31-1 interval; Node clamps such a delay to 1ms and
477
+ // busy-loops. Cap it at the 32-bit timer ceiling so the periodic sweep stays a slow tick.
478
+ const oneYearMs = 365 * 24 * 60 * 60 * 1000;
479
+ assert(Math.floor(oneYearMs / 4) > 2_147_483_647, "precondition: an unclamped year/4 overflows the timer");
480
+ assertEquals(sweepIntervalMs(oneYearMs), 2_147_483_647);
481
+ // A non-finite retention config (NaN, ±Infinity) derives a NaN interval that setInterval() coerces
482
+ // to a 1ms busy tick. Clamp any non-finite window to the same timer ceiling the overflow case uses,
483
+ // so a broken config degrades to the slowest safe sweep rather than a busy loop.
484
+ assertEquals(sweepIntervalMs(Number.NaN), 2_147_483_647);
485
+ assertEquals(sweepIntervalMs(Number.POSITIVE_INFINITY), 2_147_483_647);
486
+ assertEquals(sweepIntervalMs(Number.NEGATIVE_INFINITY), 2_147_483_647);
487
+ });
488
+
386
489
  test("drift guard: migration 024 mirrors the canonical transcript DDL byte-for-byte", async () => {
387
490
  const migrationPath = join(HERE, "..", "..", "..", "db", "migrations", "024_agentic_transcript.sql");
388
491
  const raw = await readFile(migrationPath, "utf8");
@@ -38,6 +38,28 @@ import type { AgenticContext, AgenticFamily } from "../registry.ts";
38
38
  /** The stable family name this slice registers under the seam (distinct from the wire family key). */
39
39
  export const RELAY_FAMILY_NAME = "relay";
40
40
 
41
+ /** The default retention-sweep cadence divisor: the periodic sweep runs at a fraction of the retention
42
+ * window (like the presence family runs its maintenance tick at a fraction of the presence TTL). */
43
+ const SWEEP_DIVISOR = 4;
44
+
45
+ /** Node's setInterval/setTimeout ceiling (2^31-1 ms ≈ 24.8 days). A delay above this overflows the
46
+ * 32-bit timer and Node silently clamps it to 1ms — turning a slow periodic tick into a busy loop. */
47
+ const MAX_TIMER_MS = 2_147_483_647;
48
+
49
+ /**
50
+ * The retention-sweep cadence (ms) for a given ephemeral-retention window: a fraction of the window,
51
+ * floored at 1ms and — crucially — capped at {@link MAX_TIMER_MS} so a large retention config (e.g.
52
+ * a multi-month window) cannot overflow Node's 32-bit timer and degrade the sweep into a busy loop.
53
+ * A non-finite window (NaN / ±Infinity — a broken config) derives a non-finite interval that
54
+ * setInterval() would coerce to a 1ms busy tick; clamp it to the same {@link MAX_TIMER_MS} ceiling so
55
+ * a garbage config degrades to the slowest safe sweep rather than pegging the sweep loop.
56
+ */
57
+ export function sweepIntervalMs(ephemeralRetentionMs: number): number {
58
+ if (!Number.isFinite(ephemeralRetentionMs)) return MAX_TIMER_MS;
59
+ const interval = Math.floor(ephemeralRetentionMs / SWEEP_DIVISOR);
60
+ return Math.min(MAX_TIMER_MS, Math.max(1, interval));
61
+ }
62
+
41
63
  /** Read a property off an unknown value without an unsafe `as` cast (mirrors the loader's helper). */
42
64
  function readProp(value: unknown, key: string): unknown {
43
65
  if (!value || typeof value !== "object") return undefined;
@@ -293,6 +315,12 @@ export class RelayTranscriptService {
293
315
  * in `mount` (threading the seam's hub/registry/DataLayer/log) and tears it down in `teardown`. The
294
316
  * created service is exposed to `onMounted` so a driver (H6 correlation, tests) can reach the
295
317
  * completion/reattach surface without re-mounting anything.
318
+ *
319
+ * It also (H3 read path, #222): installs the mounted service as the module singleton
320
+ * {@link currentRelayTranscriptService} — so the advisory transcript READ endpoints (`GET
321
+ * /agentic/transcripts*`) can source the {@link TranscriptStore} without re-mounting — and starts ONE
322
+ * periodic retention sweep so completed-ephemeral transcripts are actually retired past the retention
323
+ * window (the store defines the policy; this drives it, so the transcript table stays bounded).
296
324
  */
297
325
  export function createRelayFamily(options: {
298
326
  readonly relay?: RelayHubOptions;
@@ -302,6 +330,7 @@ export function createRelayFamily(options: {
302
330
  readonly onMounted?: (service: RelayTranscriptService) => void;
303
331
  } = {}): AgenticFamily {
304
332
  let service: RelayTranscriptService | undefined;
333
+ let sweepTimer: ReturnType<typeof setInterval> | undefined;
305
334
  return {
306
335
  name: RELAY_FAMILY_NAME,
307
336
  mount(ctx: AgenticContext): void {
@@ -316,15 +345,60 @@ export function createRelayFamily(options: {
316
345
  transcript: options.transcript,
317
346
  ensureSchema: options.ensureSchema,
318
347
  });
348
+ setCurrentRelayTranscriptService(service);
349
+
350
+ // Drive the store's retention-by-lifecycle policy: retire completed-ephemeral transcripts past
351
+ // the retention window on a periodic tick so the durable table does not grow unbounded. Advisory
352
+ // (a sweep fault is logged, never thrown) and never keeps the process alive on its own.
353
+ const store = service.store;
354
+ if (store) {
355
+ const interval = sweepIntervalMs(store.ephemeralRetentionMs);
356
+ const tick = () => {
357
+ try {
358
+ const retired = service?.sweep() ?? [];
359
+ if (retired.length > 0) ctx.log.info("agentic transcript retention sweep", { retired: retired.length });
360
+ } catch (err) {
361
+ ctx.log.warn("agentic transcript retention sweep failed", { err: String(err) });
362
+ }
363
+ };
364
+ sweepTimer = setInterval(tick, interval);
365
+ sweepTimer.unref?.();
366
+ // Run one sweep eagerly at mount so retention is enforced immediately: the transcript table is
367
+ // durable across restarts, so without this first pass a completed-ephemeral transcript persisted
368
+ // before downtime (and already past retention) would linger — listed by the read path — until the
369
+ // first interval tick fires (potentially far off for a large retention window). Mirrors the
370
+ // presence family's eager maintenance pass (derivation over duplication).
371
+ tick();
372
+ }
373
+
319
374
  options.onMounted?.(service);
320
375
  },
321
376
  teardown(): void {
377
+ if (sweepTimer !== undefined) {
378
+ clearInterval(sweepTimer);
379
+ sweepTimer = undefined;
380
+ }
322
381
  service?.teardown();
382
+ if (currentService === service) setCurrentRelayTranscriptService(undefined);
323
383
  service = undefined;
324
384
  },
325
385
  };
326
386
  }
327
387
 
388
+ /** The live relay service from the most recent mount, so the transcript READ endpoints (#222) can
389
+ * source the durable {@link TranscriptStore} without re-mounting the family. */
390
+ let currentService: RelayTranscriptService | undefined;
391
+
392
+ /** The mounted relay/transcript service, or undefined before mount / after teardown. */
393
+ export function currentRelayTranscriptService(): RelayTranscriptService | undefined {
394
+ return currentService;
395
+ }
396
+
397
+ /** Install the live service (called by the relay family's `mount`; cleared on `teardown`). */
398
+ export function setCurrentRelayTranscriptService(svc: RelayTranscriptService | undefined): void {
399
+ currentService = svc;
400
+ }
401
+
328
402
  /** The discovered family instance (the loader picks up this `family` export). */
329
403
  export const family: AgenticFamily = createRelayFamily();
330
404
 
@@ -0,0 +1,72 @@
1
+ // Focused unit tests for the transcript READ projection's since/until time-bounding (#222 read path).
2
+ //
3
+ // The operation-level suite (operations/listAgenticTranscripts.test.ts) proves the projection and the
4
+ // jobKey/plan filters end-to-end and that a malformed since/until 400s. This file pins the createdAt
5
+ // time-window semantics directly on listTranscripts() — inclusive boundaries, ordering, and the
6
+ // interaction with a missing/invalid createdAt — where a hand-built store lets us fix exact timestamps.
7
+ import { test } from "node:test";
8
+ import type { TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
9
+ import { assertEquals } from "#test-assert";
10
+ import { listTranscripts } from "./transcript-read.ts";
11
+
12
+ /** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
13
+ function fakeStore(metas: TranscriptStream[]): TranscriptStore {
14
+ return {
15
+ list: () => metas,
16
+ read: () => [],
17
+ } as unknown as TranscriptStore;
18
+ }
19
+
20
+ function meta(stream: string, createdAt: string): TranscriptStream {
21
+ return { stream, lifecycle: "ephemeral", status: "completed", createdAt, nextOffset: 0 };
22
+ }
23
+
24
+ const early = "2026-01-01T00:00:00.000Z";
25
+ const mid = "2026-06-15T12:00:00.000Z";
26
+ const late = "2026-12-31T23:59:59.000Z";
27
+
28
+ test("listTranscripts: no since/until returns everything, newest-first", () => {
29
+ const store = fakeStore([meta("job:a", early), meta("job:b", late), meta("job:c", mid)]);
30
+ const out = listTranscripts(store, undefined);
31
+ assertEquals(
32
+ out.map((t) => t.stream),
33
+ ["job:b", "job:c", "job:a"],
34
+ );
35
+ });
36
+
37
+ test("listTranscripts: since is an inclusive lower bound on createdAt", () => {
38
+ const store = fakeStore([meta("job:early", early), meta("job:mid", mid), meta("job:late", late)]);
39
+ // A session created exactly at `since` is retained (inclusive); earlier ones are dropped.
40
+ const out = listTranscripts(store, undefined, { since: mid });
41
+ assertEquals(
42
+ out.map((t) => t.stream),
43
+ ["job:late", "job:mid"],
44
+ );
45
+ });
46
+
47
+ test("listTranscripts: until is an inclusive upper bound on createdAt", () => {
48
+ const store = fakeStore([meta("job:early", early), meta("job:mid", mid), meta("job:late", late)]);
49
+ // A session created exactly at `until` is retained (inclusive); later ones are dropped.
50
+ const out = listTranscripts(store, undefined, { until: mid });
51
+ assertEquals(
52
+ out.map((t) => t.stream),
53
+ ["job:mid", "job:early"],
54
+ );
55
+ });
56
+
57
+ test("listTranscripts: since+until bound a window on both sides (inclusive)", () => {
58
+ const store = fakeStore([meta("job:early", early), meta("job:mid", mid), meta("job:late", late)]);
59
+ const out = listTranscripts(store, undefined, { since: mid, until: mid });
60
+ assertEquals(
61
+ out.map((t) => t.stream),
62
+ ["job:mid"],
63
+ );
64
+ });
65
+
66
+ test("listTranscripts: a session with an unparseable createdAt is retained regardless of the window", () => {
67
+ // Date.parse() of a garbage createdAt is NaN; the guard skips both bounds, so the row is never
68
+ // silently dropped by a time filter (its context is still recoverable from the stream id).
69
+ const store = fakeStore([meta("job:mid", mid), meta("job:bad", "not-a-date")]);
70
+ const out = listTranscripts(store, undefined, { since: late });
71
+ assertEquals(new Set(out.map((t) => t.stream)), new Set(["job:bad"]));
72
+ });
@@ -0,0 +1,161 @@
1
+ // nano-workforce — the transcript READ projection (ADR 0056, H3 / #146, read path #222).
2
+ //
3
+ // The write path (relay.family.ts) flushes an ephemeral agent's PTY stream to a durable transcript on
4
+ // job completion; this module is the READ counterpart the advisory `GET /agentic/transcripts*`
5
+ // endpoints share. It projects a {@link TranscriptStore} row (+ its retained chunks) onto the wire
6
+ // shape and enriches it with the H6 correlation (`app/agentic/correlation.ts`) so a captured session
7
+ // lines up with "that process instance / this plan" — even after the ephemeral agent has exited.
8
+ //
9
+ // Correlation is BEST-EFFORT and advisory: the correlation registry is in-memory and only holds
10
+ // currently-linked jobs, so a completed session's process-instance / plan context is present only
11
+ // while the job is still live. The jobKey itself is always recoverable — it is encoded in the stream
12
+ // id (`job:<jobKey>`), so a past session is never anonymous even once its correlation has been released.
13
+ //
14
+ // Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
15
+ // unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
16
+
17
+ import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
+ import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
19
+ import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
20
+
21
+ /** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
22
+ export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
23
+ let total = 0;
24
+ for (const c of chunks) total += Buffer.byteLength(c.chunk, "utf8");
25
+ return total;
26
+ }
27
+
28
+ /** The correlation fields (jobKey + engine context) a stream id resolves to, best-effort. */
29
+ interface CorrelationFields {
30
+ jobKey?: string;
31
+ processInstanceKey?: string;
32
+ bpmnProcessId?: string;
33
+ elementId?: string;
34
+ planKey?: string;
35
+ }
36
+
37
+ /**
38
+ * Resolve a stream id to its correlation fields: the jobKey is always decoded from a `job:<jobKey>`
39
+ * stream id; the engine context (process instance / plan) is added only when the correlation registry
40
+ * still holds the (live) job. Non-job streams yield an empty object.
41
+ */
42
+ export function correlationFieldsFor(stream: string, correlation: CorrelationRegistry | undefined): CorrelationFields {
43
+ const jobKey = jobKeyOfStream(stream);
44
+ if (jobKey === undefined) return {};
45
+ const fields: CorrelationFields = { jobKey };
46
+ const context = correlation?.resolve(jobKey);
47
+ if (context) {
48
+ if (context.processInstanceKey !== undefined) fields.processInstanceKey = context.processInstanceKey;
49
+ if (context.bpmnProcessId !== undefined) fields.bpmnProcessId = context.bpmnProcessId;
50
+ if (context.elementId !== undefined) fields.elementId = context.elementId;
51
+ if (context.planKey !== undefined) fields.planKey = context.planKey;
52
+ }
53
+ return fields;
54
+ }
55
+
56
+ /** Project a stored transcript's metadata (+ its retained chunks) onto the list wire shape. */
57
+ export function toTranscript(
58
+ meta: TranscriptStream,
59
+ store: TranscriptStore,
60
+ correlation: CorrelationRegistry | undefined,
61
+ ): AgenticTranscript {
62
+ const chunks = store.read(meta.stream);
63
+ const out: AgenticTranscript = {
64
+ stream: meta.stream,
65
+ lifecycle: meta.lifecycle,
66
+ status: meta.status,
67
+ createdAt: meta.createdAt,
68
+ nextOffset: meta.nextOffset,
69
+ byteLength: byteLengthOf(chunks),
70
+ chunkCount: chunks.length,
71
+ };
72
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
73
+ if (meta.firstOffset !== undefined) out.firstOffset = meta.firstOffset;
74
+ const fields = correlationFieldsFor(meta.stream, correlation);
75
+ if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
76
+ if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
77
+ if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
78
+ if (fields.elementId !== undefined) out.elementId = fields.elementId;
79
+ if (fields.planKey !== undefined) out.planKey = fields.planKey;
80
+ return out;
81
+ }
82
+
83
+ /** The filters {@link listTranscripts} understands (all optional; an empty filter returns everything). */
84
+ export interface TranscriptFilter {
85
+ readonly jobKey?: string;
86
+ readonly processInstanceKey?: string;
87
+ readonly planKey?: string;
88
+ /** ISO-8601 lower bound (inclusive) on the session's createdAt. */
89
+ readonly since?: string;
90
+ /** ISO-8601 upper bound (inclusive) on the session's createdAt. */
91
+ readonly until?: string;
92
+ }
93
+
94
+ /**
95
+ * List every captured session projected to the wire shape, sorted newest-first by createdAt (then by
96
+ * stream for a stable tie-break), after applying the (advisory) filters. jobKey / process-instance /
97
+ * plan filters match the correlation-enriched fields; since/until bound createdAt.
98
+ */
99
+ export function listTranscripts(
100
+ store: TranscriptStore,
101
+ correlation: CorrelationRegistry | undefined,
102
+ filter: TranscriptFilter = {},
103
+ ): AgenticTranscript[] {
104
+ const sinceMs = filter.since !== undefined ? Date.parse(filter.since) : undefined;
105
+ const untilMs = filter.until !== undefined ? Date.parse(filter.until) : undefined;
106
+ const rows = store
107
+ .list()
108
+ .map((meta) => toTranscript(meta, store, correlation))
109
+ .filter((t) => {
110
+ if (filter.jobKey !== undefined && t.jobKey !== filter.jobKey) return false;
111
+ if (filter.processInstanceKey !== undefined && t.processInstanceKey !== filter.processInstanceKey) return false;
112
+ if (filter.planKey !== undefined && t.planKey !== filter.planKey) return false;
113
+ const createdMs = Date.parse(t.createdAt);
114
+ if (sinceMs !== undefined && Number.isFinite(createdMs) && createdMs < sinceMs) return false;
115
+ if (untilMs !== undefined && Number.isFinite(createdMs) && createdMs > untilMs) return false;
116
+ return true;
117
+ });
118
+ // Newest session first (a "past sessions" feed reads best most-recent-first); stable on stream id.
119
+ rows.sort((a, b) => {
120
+ const byTime = b.createdAt.localeCompare(a.createdAt);
121
+ return byTime !== 0 ? byTime : a.stream.localeCompare(b.stream);
122
+ });
123
+ return rows;
124
+ }
125
+
126
+ /**
127
+ * Fetch a stored transcript's bytes from offset `from` (inclusive), projected onto the range/offset
128
+ * wire shape — the SAME resume-from-offset contract the live terminal renders, so the cockpit replays
129
+ * a closed stream through its existing renderer. Returns undefined when the stream has no transcript.
130
+ */
131
+ export function readTranscriptFrom(
132
+ stream: string,
133
+ from: number,
134
+ store: TranscriptStore,
135
+ correlation: CorrelationRegistry | undefined,
136
+ ): AgenticTranscriptData | undefined {
137
+ const meta = store.get(stream);
138
+ if (meta === undefined) return undefined;
139
+ const slice = store.since(stream, from);
140
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
141
+ const out: AgenticTranscriptData = {
142
+ stream: meta.stream,
143
+ lifecycle: meta.lifecycle,
144
+ status: meta.status,
145
+ createdAt: meta.createdAt,
146
+ nextOffset: slice.nextOffset,
147
+ byteLength: byteLengthOf(slice.entries),
148
+ chunkCount: entries.length,
149
+ from,
150
+ gap: slice.gap,
151
+ entries,
152
+ };
153
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
154
+ const fields = correlationFieldsFor(meta.stream, correlation);
155
+ if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
156
+ if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
157
+ if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
158
+ if (fields.elementId !== undefined) out.elementId = fields.elementId;
159
+ if (fields.planKey !== undefined) out.planKey = fields.planKey;
160
+ return out;
161
+ }