@nanobpm/nano-workforce 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -0
- package/CHANGELOG.md +14 -0
- package/README.md +10 -8
- package/app/agentic/cockpit/index.ts +18 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
- package/app/agentic/cockpit/supply-boot.test.ts +34 -0
- package/app/agentic/cockpit/supply-boot.ts +256 -21
- package/app/agentic/cockpit/transcript-render.test.ts +110 -0
- package/app/agentic/cockpit/transcript-render.ts +136 -0
- package/app/agentic/cockpit/transcript-view.test.ts +61 -0
- package/app/agentic/cockpit/transcript-view.ts +131 -0
- package/app/agentic/families/relay.family.test.ts +103 -0
- package/app/agentic/families/relay.family.ts +74 -0
- package/app/agentic/transcript-read.test.ts +72 -0
- package/app/agentic/transcript-read.ts +161 -0
- package/app/blackboard.test.ts +15 -7
- package/app/blackboard.ts +4 -4
- package/openapi.yaml +267 -0
- package/operations/getAgenticTranscript.test.ts +165 -0
- package/operations/getAgenticTranscript.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +169 -0
- package/operations/listAgenticTranscripts.ts +61 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +70 -0
- package/pages/cockpit/mount.js +254 -13
- package/pages/cockpit.page.json +1 -1
- package/resources/processes/convergence-loop.bpmn +1 -1
- package/resources/processes/feature.bpmn +1 -1
- package/resources/processes/merge-loop.bpmn +2 -2
- package/resources/processes/plan-fanout.bpmn +4 -4
- package/resources/processes/retro.bpmn +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The cockpit "past sessions" DOM renderer + static-replay helper (ADR 0056, H3 read path / #222).
|
|
2
|
+
//
|
|
3
|
+
// Renders a {@link TranscriptsView} into a host element: the captured-session history list the cockpit
|
|
4
|
+
// shows BESIDE the live supply list. Clicking a session calls {@link RenderTranscriptsOptions.onReplay}
|
|
5
|
+
// with that session's relay stream id, which the boot layer turns into a STATIC playback in the same
|
|
6
|
+
// persistent terminal region a live drill-in uses — clearly distinguished as "replayed" (a closed
|
|
7
|
+
// stream) vs "live". Like the supply renderer it draws only the *volatile* history list; the terminal
|
|
8
|
+
// itself is owned by the boot layer's persistent region.
|
|
9
|
+
//
|
|
10
|
+
// It builds against the structural {@link ElementLike} / {@link DocumentLike} subset (reused from the
|
|
11
|
+
// package) rather than the global `document`, so the real DOM satisfies it at runtime AND a plain
|
|
12
|
+
// in-memory fake satisfies it for DOM-free Node tests.
|
|
13
|
+
//
|
|
14
|
+
// The static-replay helper ({@link replayTranscript}) drives a `@nanobpm/agentic/cockpit`
|
|
15
|
+
// {@link TerminalSession} from a fetched transcript page: it feeds the stored chunks through the SAME
|
|
16
|
+
// resume-from-offset renderer a LIVE stream uses, so a closed session replays faithfully with no live
|
|
17
|
+
// worker and no relay connection.
|
|
18
|
+
import type { DocumentLike, ElementLike, TerminalSession } from "@nanobpm/agentic/cockpit";
|
|
19
|
+
import type { TranscriptsView, TranscriptView } from "./transcript-view.ts";
|
|
20
|
+
|
|
21
|
+
export interface RenderTranscriptsOptions {
|
|
22
|
+
/** Called with a session's relay stream id when the operator selects it to replay. */
|
|
23
|
+
readonly onReplay?: (stream: string) => void;
|
|
24
|
+
/** The stream currently being replayed, if any — highlighted in the list. */
|
|
25
|
+
readonly activeStream?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Handles into the rendered tree the caller may need. */
|
|
29
|
+
export interface TranscriptsDom {
|
|
30
|
+
readonly root: ElementLike;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
|
|
34
|
+
const node = doc.createElement(tag);
|
|
35
|
+
if (className !== undefined) node.className = className;
|
|
36
|
+
if (text !== undefined) node.textContent = text;
|
|
37
|
+
return node;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sessionRow(doc: DocumentLike, session: TranscriptView, options: RenderTranscriptsOptions): ElementLike {
|
|
41
|
+
const row = el(doc, "tr", "cockpit-past-session");
|
|
42
|
+
row.setAttribute("data-stream", session.stream);
|
|
43
|
+
row.setAttribute("data-status", session.status);
|
|
44
|
+
if (session.jobKey !== undefined) row.setAttribute("data-job-key", session.jobKey);
|
|
45
|
+
if (options.activeStream === session.stream) row.setAttribute("data-active", "true");
|
|
46
|
+
|
|
47
|
+
const nameCell = el(doc, "td", "cockpit-td cockpit-past-name");
|
|
48
|
+
const button = el(doc, "button", "cockpit-past-replay", session.label);
|
|
49
|
+
button.setAttribute("type", "button");
|
|
50
|
+
button.setAttribute("data-stream", session.stream);
|
|
51
|
+
const onReplay = options.onReplay;
|
|
52
|
+
if (onReplay !== undefined) button.addEventListener("click", () => onReplay(session.stream));
|
|
53
|
+
nameCell.appendChild(button);
|
|
54
|
+
row.appendChild(nameCell);
|
|
55
|
+
|
|
56
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-past-status", session.status));
|
|
57
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-past-size", session.size));
|
|
58
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-past-captured", session.capturedAt));
|
|
59
|
+
return row;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render `view` into `host`, replacing whatever was there. Idempotent: call it again on every refresh
|
|
64
|
+
* to reflect the latest captured-session snapshot.
|
|
65
|
+
*/
|
|
66
|
+
export function renderTranscripts(
|
|
67
|
+
host: ElementLike,
|
|
68
|
+
doc: DocumentLike,
|
|
69
|
+
view: TranscriptsView,
|
|
70
|
+
options: RenderTranscriptsOptions = {},
|
|
71
|
+
): TranscriptsDom {
|
|
72
|
+
host.replaceChildren();
|
|
73
|
+
const root = el(doc, "div", "cockpit-past");
|
|
74
|
+
root.setAttribute("data-session-count", String(view.count));
|
|
75
|
+
|
|
76
|
+
const header = el(doc, "header", "cockpit-past-header");
|
|
77
|
+
header.appendChild(el(doc, "h2", "cockpit-past-title", "Past sessions"));
|
|
78
|
+
const summary = el(doc, "span", "cockpit-past-summary", view.retention !== undefined ? `${view.count} · kept ${view.retention}` : `${view.count}`);
|
|
79
|
+
summary.setAttribute("data-summary", "past");
|
|
80
|
+
header.appendChild(summary);
|
|
81
|
+
root.appendChild(header);
|
|
82
|
+
|
|
83
|
+
if (view.count === 0) {
|
|
84
|
+
const empty = el(doc, "div", "cockpit-past-empty", "No captured sessions yet.");
|
|
85
|
+
empty.setAttribute("data-empty", "true");
|
|
86
|
+
root.appendChild(empty);
|
|
87
|
+
host.appendChild(root);
|
|
88
|
+
return { root };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const table = el(doc, "table", "cockpit-past-table");
|
|
92
|
+
const thead = el(doc, "thead", "cockpit-past-thead");
|
|
93
|
+
const head = el(doc, "tr", "cockpit-past-head");
|
|
94
|
+
for (const label of ["session", "status", "size", "captured"]) head.appendChild(el(doc, "th", "cockpit-th", label));
|
|
95
|
+
thead.appendChild(head);
|
|
96
|
+
table.appendChild(thead);
|
|
97
|
+
const tbody = el(doc, "tbody", "cockpit-past-tbody");
|
|
98
|
+
for (const session of view.sessions) tbody.appendChild(sessionRow(doc, session, options));
|
|
99
|
+
table.appendChild(tbody);
|
|
100
|
+
root.appendChild(table);
|
|
101
|
+
|
|
102
|
+
host.appendChild(root);
|
|
103
|
+
return { root };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One stored transcript chunk as the fetch endpoint returns it (mirrors `AgenticTranscriptChunk`). */
|
|
107
|
+
export interface TranscriptChunkReport {
|
|
108
|
+
readonly offset: number;
|
|
109
|
+
readonly chunk: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A stored transcript's bytes as `GET /agentic/transcripts/{stream}` returns them (mirrors `AgenticTranscriptData`). */
|
|
113
|
+
export interface TranscriptDataReport {
|
|
114
|
+
readonly stream: string;
|
|
115
|
+
readonly from: number;
|
|
116
|
+
readonly gap: boolean;
|
|
117
|
+
readonly nextOffset: number;
|
|
118
|
+
readonly entries: readonly TranscriptChunkReport[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Statically replay a fetched transcript into a {@link TerminalSession}: feed the stored chunks through
|
|
123
|
+
* the SAME resume-from-offset handler a live stream uses, so a closed session renders faithfully. The
|
|
124
|
+
* session must be constructed with `from` equal to `data.from` (so it does not drop the leading chunks
|
|
125
|
+
* as already-applied) and a no-op `send` (there is no live relay to talk to). Returns the count written.
|
|
126
|
+
*/
|
|
127
|
+
export function replayTranscript(session: TerminalSession, data: TranscriptDataReport): number {
|
|
128
|
+
// The resume ack first (records any retention gap), then the stored chunks in offset order.
|
|
129
|
+
session.handle({ op: "subscribed", stream: data.stream, gap: data.gap, nextOffset: data.nextOffset });
|
|
130
|
+
let written = 0;
|
|
131
|
+
for (const entry of data.entries) {
|
|
132
|
+
session.handle({ stream: data.stream, offset: entry.offset, chunk: entry.chunk });
|
|
133
|
+
written++;
|
|
134
|
+
}
|
|
135
|
+
return written;
|
|
136
|
+
}
|
|
@@ -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
|
+
});
|