@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.
- package/AGENTS.md +18 -0
- package/CHANGELOG.md +7 -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
|
@@ -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
|
+
}
|