@nanobpm/nano-workforce 0.78.0 → 0.79.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/CHANGELOG.md +7 -0
- package/app/agentic/README.md +20 -0
- package/app/agentic/cockpit/index.ts +5 -0
- package/app/agentic/cockpit/transcript-derive.test.ts +78 -0
- package/app/agentic/cockpit/transcript-derive.ts +90 -0
- package/app/agentic/transcript-events.drift.test.ts +55 -0
- package/app/agentic/transcript-events.test.ts +186 -0
- package/app/agentic/transcript-events.ts +470 -0
- package/app/agentic/transcript-fork.test.ts +156 -0
- package/app/agentic/transcript-fork.ts +151 -0
- package/app/agentic/transcript-read.ts +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.79.0](https://github.com/nanobpm/nano-workforce/compare/v0.78.0...v0.79.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **agentic:** event-sourced transcripts — typed vocabulary + derive fold + replay-by-fork ([#251](https://github.com/nanobpm/nano-workforce/issues/251)) ([#252](https://github.com/nanobpm/nano-workforce/issues/252)) ([afa7190](https://github.com/nanobpm/nano-workforce/commit/afa7190db2c19e088ae3fb058a2b43d72f54a8df)), closes [146/#222](https://github.com/nanobpm/nano-workforce/issues/222)
|
|
7
|
+
|
|
1
8
|
# [0.78.0](https://github.com/nanobpm/nano-workforce/compare/v0.77.0...v0.78.0) (2026-08-17)
|
|
2
9
|
|
|
3
10
|
|
package/app/agentic/README.md
CHANGED
|
@@ -50,6 +50,26 @@ H0 itself needs no migration.
|
|
|
50
50
|
present as `?token=…`. When neither is set, the channel is **not mounted** (logged), so the app
|
|
51
51
|
never exposes an unauthenticated upgrade.
|
|
52
52
|
|
|
53
|
+
## Event-sourced transcripts (#251)
|
|
54
|
+
|
|
55
|
+
The H3 transcript store (`db/migrations/024_agentic_transcript.sql`) is already **append-only and
|
|
56
|
+
offset-keyed** — the log half of dsh's event-sourced-session pattern. `transcript-events.ts` adds the
|
|
57
|
+
derivation half:
|
|
58
|
+
|
|
59
|
+
- **Typed, merge-extensible event vocabulary** — `parseTranscriptEvent` is the **one** parser that
|
|
60
|
+
classifies each stored chunk into a typed `TranscriptEvent` (`message` / `tool-call` / `tool-result`
|
|
61
|
+
/ `turn` / `step` / `lifecycle`, plus `stream-chunk` for raw terminal bytes retained **verbatim** for
|
|
62
|
+
byte-replay fidelity). A structured producer tags a chunk with the `nwfTranscriptEvent` marker;
|
|
63
|
+
anything else stays a raw `stream-chunk`. Authors extend the vocabulary additively with
|
|
64
|
+
`mergeTranscriptVocab` — never a second parser.
|
|
65
|
+
- **One `deriveView()` fold** — every higher-level view (the cockpit's structured message/tool/turn
|
|
66
|
+
view in `cockpit/transcript-derive.ts`, and any future search / token-accounting / export consumer)
|
|
67
|
+
is a **derivation** of the single log: "the log IS the state". `transcript-events.drift.test.ts`
|
|
68
|
+
asserts exactly one parser (no consumer re-parses raw bytes).
|
|
69
|
+
- **Replay-by-reseed / fork** — `transcript-fork.ts` seeds a **new** stream from an existing log up to
|
|
70
|
+
a chosen offset, so an exited session can be branched and replayed independently, offset-parity
|
|
71
|
+
preserved. Byte-replay and resume-from-offset (`transcript-read.ts`) are untouched.
|
|
72
|
+
|
|
53
73
|
## Invariants (ADR 0056)
|
|
54
74
|
|
|
55
75
|
- **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;
|
|
@@ -29,6 +29,11 @@ export {
|
|
|
29
29
|
type SupplyWorkerView,
|
|
30
30
|
supplyView,
|
|
31
31
|
} from "./supply-view.ts";
|
|
32
|
+
export {
|
|
33
|
+
type DerivedTranscriptDom,
|
|
34
|
+
deriveTranscript,
|
|
35
|
+
renderDerivedTranscript,
|
|
36
|
+
} from "./transcript-derive.ts";
|
|
32
37
|
export {
|
|
33
38
|
type RenderTranscriptsOptions,
|
|
34
39
|
renderTranscripts,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Unit tests for the cockpit STRUCTURED transcript view derived from the one fold (#251).
|
|
2
|
+
//
|
|
3
|
+
// Proves the cockpit's structured view is a DERIVATION of the typed event log (message history, tool
|
|
4
|
+
// cards, per-turn boundaries), and that raw chunks are preserved in the fidelity footer — the byte
|
|
5
|
+
// replay is not lost. It renders into the in-memory DOM double, no browser.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
|
|
9
|
+
import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
|
|
10
|
+
import { deriveTranscript, renderDerivedTranscript } from "./transcript-derive.ts";
|
|
11
|
+
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
12
|
+
|
|
13
|
+
const doc = new FakeDocument();
|
|
14
|
+
|
|
15
|
+
function env(kind: string, extra: Record<string, unknown> = {}): string {
|
|
16
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function report(): TranscriptDataReport {
|
|
20
|
+
return {
|
|
21
|
+
stream: "job:1",
|
|
22
|
+
from: 0,
|
|
23
|
+
gap: false,
|
|
24
|
+
nextOffset: 6,
|
|
25
|
+
entries: [
|
|
26
|
+
{ offset: 0, chunk: env("turn", { index: 0 }) },
|
|
27
|
+
{ offset: 1, chunk: env("message", { role: "user", text: "please build it" }) },
|
|
28
|
+
{ offset: 2, chunk: env("tool-call", { name: "grep", callId: "c1" }) },
|
|
29
|
+
{ offset: 3, chunk: env("tool-result", { callId: "c1", ok: true, content: "hit" }) },
|
|
30
|
+
{ offset: 4, chunk: "\u001b[2Jraw terminal frame" },
|
|
31
|
+
{ offset: 5, chunk: env("message", { role: "assistant", text: "done" }) },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("deriveTranscript folds the fetched page into structured turns/messages/tools", () => {
|
|
37
|
+
const view = deriveTranscript(report());
|
|
38
|
+
assertEquals(view.turns.length, 1);
|
|
39
|
+
assertEquals(view.messages.map((m) => m.text), ["please build it", "done"]);
|
|
40
|
+
assertEquals(view.tools.length, 1);
|
|
41
|
+
assertEquals(view.tools[0]?.result?.ok, true);
|
|
42
|
+
assertEquals(view.rawChunkCount, 1); // the one raw terminal frame is retained
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("renderDerivedTranscript draws a turn section with derived messages and tool cards", () => {
|
|
46
|
+
const host = new FakeElement("div");
|
|
47
|
+
renderDerivedTranscript(host, doc, report());
|
|
48
|
+
assertEquals(host.byData("turn-count", "1").length, 1);
|
|
49
|
+
assertEquals(host.byData("message-count", "2").length, 1);
|
|
50
|
+
assertEquals(host.byData("tool-count", "1").length, 1);
|
|
51
|
+
const tool = host.byData("tool", "grep")[0];
|
|
52
|
+
assertEquals(tool?.getAttribute("data-status"), "ok");
|
|
53
|
+
const roles = host.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role"));
|
|
54
|
+
assertEquals(roles, ["user", "assistant"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("renderDerivedTranscript keeps the raw-fidelity footer so byte replay is visibly preserved", () => {
|
|
58
|
+
const host = new FakeElement("div");
|
|
59
|
+
renderDerivedTranscript(host, doc, report());
|
|
60
|
+
const footer = host.byClass("cockpit-transcript-raw")[0];
|
|
61
|
+
assertEquals(footer?.getAttribute("data-raw-chunks"), "1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("renderDerivedTranscript shows an empty state for an all-raw (unstructured) transcript", () => {
|
|
65
|
+
const host = new FakeElement("div");
|
|
66
|
+
renderDerivedTranscript(host, doc, {
|
|
67
|
+
stream: "job:2",
|
|
68
|
+
from: 0,
|
|
69
|
+
gap: false,
|
|
70
|
+
nextOffset: 2,
|
|
71
|
+
entries: [
|
|
72
|
+
{ offset: 0, chunk: "just raw\n" },
|
|
73
|
+
{ offset: 1, chunk: "bytes\n" },
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
assertEquals(host.byData("empty", "true").length, 1);
|
|
77
|
+
assertEquals(host.byData("turn-count", "0").length, 1);
|
|
78
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The cockpit STRUCTURED transcript view — derived from the one event fold (ADR 0056, #251).
|
|
2
|
+
//
|
|
3
|
+
// Beside the byte-level replay (`transcript-render.ts` feeds stored chunks through the live terminal
|
|
4
|
+
// renderer for pixel-faithful playback), the cockpit can also show a STRUCTURED view of a captured
|
|
5
|
+
// session: its derived message history, tool cards and per-turn boundaries. Per the issue's acceptance
|
|
6
|
+
// criterion, that structured view is a DERIVATION of the one typed event log — it re-parses nothing.
|
|
7
|
+
// It reads a fetched transcript page and folds it through the single {@link deriveViewFromChunks} entry
|
|
8
|
+
// point (which routes every chunk through the ONE parser, `parseTranscriptEvent`), so there is no
|
|
9
|
+
// second parser of the raw bytes. The drift-guard test enforces that this module never parses chunks
|
|
10
|
+
// itself.
|
|
11
|
+
//
|
|
12
|
+
// Framework-free and side-effect-free, like the sibling cockpit views: the same report always yields
|
|
13
|
+
// the same {@link DerivedView}, and the renderer draws into the injected {@link DocumentLike} subset so
|
|
14
|
+
// a real DOM satisfies it at runtime and an in-memory fake satisfies it for DOM-free Node tests.
|
|
15
|
+
import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
|
|
16
|
+
import { type DerivedView, deriveViewFromChunks } from "../transcript-events.ts";
|
|
17
|
+
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Derive the structured view of a fetched transcript page by folding its stored chunks through the ONE
|
|
21
|
+
* event parser + fold. Pure: the cockpit reads THIS instead of re-parsing raw frame bytes.
|
|
22
|
+
*/
|
|
23
|
+
export function deriveTranscript(data: TranscriptDataReport): DerivedView {
|
|
24
|
+
return deriveViewFromChunks(data.entries);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
|
|
28
|
+
const node = doc.createElement(tag);
|
|
29
|
+
if (className !== undefined) node.className = className;
|
|
30
|
+
if (text !== undefined) node.textContent = text;
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Handles into the rendered structured tree the caller may need. */
|
|
35
|
+
export interface DerivedTranscriptDom {
|
|
36
|
+
readonly root: ElementLike;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
|
|
41
|
+
* Draws per-turn sections with their derived messages and tool cards, plus a raw-fidelity footer
|
|
42
|
+
* (retained bytes/chunks) so the operator sees the byte-replay is preserved alongside the structure.
|
|
43
|
+
* Idempotent — call again on each refresh. Everything it shows is a derivation of the one event log.
|
|
44
|
+
*/
|
|
45
|
+
export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, data: TranscriptDataReport): DerivedTranscriptDom {
|
|
46
|
+
const view = deriveTranscript(data);
|
|
47
|
+
host.replaceChildren();
|
|
48
|
+
const root = el(doc, "div", "cockpit-transcript-derived");
|
|
49
|
+
root.setAttribute("data-stream", data.stream);
|
|
50
|
+
root.setAttribute("data-lifecycle", view.lifecycle);
|
|
51
|
+
root.setAttribute("data-turn-count", String(view.turns.length));
|
|
52
|
+
root.setAttribute("data-message-count", String(view.messages.length));
|
|
53
|
+
root.setAttribute("data-tool-count", String(view.tools.length));
|
|
54
|
+
|
|
55
|
+
if (view.turns.length === 0) {
|
|
56
|
+
const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
|
|
57
|
+
empty.setAttribute("data-empty", "true");
|
|
58
|
+
root.appendChild(empty);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const turn of view.turns) {
|
|
62
|
+
const section = el(doc, "section", "cockpit-transcript-turn");
|
|
63
|
+
section.setAttribute("data-turn", String(turn.index));
|
|
64
|
+
section.setAttribute("data-steps", String(turn.steps));
|
|
65
|
+
section.appendChild(el(doc, "h3", "cockpit-transcript-turn-title", `Turn ${turn.index}`));
|
|
66
|
+
for (const msg of turn.messages) {
|
|
67
|
+
const row = el(doc, "div", "cockpit-transcript-message", msg.text);
|
|
68
|
+
row.setAttribute("data-role", msg.role);
|
|
69
|
+
row.setAttribute("data-offset", String(msg.offset));
|
|
70
|
+
section.appendChild(row);
|
|
71
|
+
}
|
|
72
|
+
for (const tool of turn.tools) {
|
|
73
|
+
const card = el(doc, "div", "cockpit-transcript-tool", tool.name);
|
|
74
|
+
card.setAttribute("data-tool", tool.name);
|
|
75
|
+
card.setAttribute("data-offset", String(tool.offset));
|
|
76
|
+
card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
77
|
+
section.appendChild(card);
|
|
78
|
+
}
|
|
79
|
+
root.appendChild(section);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const footer = el(doc, "footer", "cockpit-transcript-raw");
|
|
83
|
+
footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
|
|
84
|
+
footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
|
|
85
|
+
footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
|
|
86
|
+
root.appendChild(footer);
|
|
87
|
+
|
|
88
|
+
host.appendChild(root);
|
|
89
|
+
return { root };
|
|
90
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Drift-guard: exactly ONE parser of the transcript log (ADR 0056, #251).
|
|
2
|
+
//
|
|
3
|
+
// Acceptance criterion (#251): "the cockpit renders from a single derive*() fold, with no independent
|
|
4
|
+
// re-parse of raw bytes (drift-guard test asserts one parser)". This is that guard. It enforces
|
|
5
|
+
// structurally — by scanning the app-tier source — that the raw-chunk → typed-event classification
|
|
6
|
+
// lives in exactly one module (`transcript-events.ts`), so a second, divergent parser of the same
|
|
7
|
+
// bytes cannot creep in. The whole point of the event-sourced model is "the log IS the state": every
|
|
8
|
+
// view derives from the one fold, none re-parses the bytes itself.
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import { TRANSCRIPT_EVENT_MARKER } from "./transcript-events.ts";
|
|
15
|
+
|
|
16
|
+
const AGENTIC_DIR = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
/** Every non-test `.ts` source file under app/agentic, recursively. */
|
|
19
|
+
function sourceFiles(dir: string): string[] {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
22
|
+
const path = join(dir, entry.name);
|
|
23
|
+
if (entry.isDirectory()) out.push(...sourceFiles(path));
|
|
24
|
+
else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) out.push(path);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PARSER_MODULE = join(AGENTIC_DIR, "transcript-events.ts");
|
|
30
|
+
|
|
31
|
+
test("the transcript-event marker literal is DEFINED in exactly one module (no second parser)", () => {
|
|
32
|
+
// Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only the ONE
|
|
33
|
+
// parser embeds the marker's string literal. A second module hardcoding it would be a second parser.
|
|
34
|
+
// Match every quote form (double, single, backtick) so a second parser can't bypass the guard by
|
|
35
|
+
// hardcoding the marker in a different literal style.
|
|
36
|
+
const quotedMarkerForms = ['"', "'", "`"].map((q) => `${q}${TRANSCRIPT_EVENT_MARKER}${q}`);
|
|
37
|
+
const owners = sourceFiles(AGENTIC_DIR).filter((path) => {
|
|
38
|
+
const src = readFileSync(path, "utf8");
|
|
39
|
+
return quotedMarkerForms.some((literal) => src.includes(literal));
|
|
40
|
+
});
|
|
41
|
+
assertEquals(owners, [PARSER_MODULE]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no transcript consumer re-parses raw chunks — JSON.parse of the log lives only in the parser", () => {
|
|
45
|
+
// The cockpit + read projections must fold through the single parser, never JSON.parse a chunk
|
|
46
|
+
// themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
|
|
47
|
+
const consumers = sourceFiles(AGENTIC_DIR).filter(
|
|
48
|
+
(path) => path !== PARSER_MODULE && /transcript-(read|render|view|derive|fork)\.ts$/.test(path),
|
|
49
|
+
);
|
|
50
|
+
assert(consumers.length >= 3, "expected to scan several transcript consumers");
|
|
51
|
+
for (const path of consumers) {
|
|
52
|
+
const src = readFileSync(path, "utf8");
|
|
53
|
+
assert(!src.includes("JSON.parse"), `${path} must derive through parseTranscriptEvent, not re-parse the log itself`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Unit tests for the typed transcript-event vocabulary + the single derive() fold (#251).
|
|
2
|
+
//
|
|
3
|
+
// Pins: the ONE parser classifies raw bytes vs typed envelopes (raw fidelity preserved), the core
|
|
4
|
+
// vocabulary decodes each kind, merge-extensibility adds/overrides kinds without a second parser,
|
|
5
|
+
// encode↔parse round-trips, and deriveView folds the log into per-turn structure / message history /
|
|
6
|
+
// tool cards / raw-byte accounting / lifecycle — "the log IS the state".
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import {
|
|
10
|
+
CORE_TRANSCRIPT_VOCAB,
|
|
11
|
+
deriveView,
|
|
12
|
+
deriveViewFromChunks,
|
|
13
|
+
encodeTranscriptEvent,
|
|
14
|
+
mergeTranscriptVocab,
|
|
15
|
+
parseTranscriptEvent,
|
|
16
|
+
type TranscriptEvent,
|
|
17
|
+
TRANSCRIPT_EVENT_MARKER,
|
|
18
|
+
TRANSCRIPT_EVENT_VERSION,
|
|
19
|
+
} from "./transcript-events.ts";
|
|
20
|
+
|
|
21
|
+
function env(kind: string, extra: Record<string, unknown> = {}): string {
|
|
22
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("parseTranscriptEvent: raw terminal bytes are retained verbatim as a stream-chunk", () => {
|
|
26
|
+
const event = parseTranscriptEvent({ offset: 3, chunk: "\u001b[32mok\u001b[0m\r\n" });
|
|
27
|
+
assertEquals(event, { kind: "stream-chunk", offset: 3, chunk: "\u001b[32mok\u001b[0m\r\n" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("parseTranscriptEvent: JSON without the marker is NOT mis-classified — stays a raw chunk", () => {
|
|
31
|
+
const chunk = JSON.stringify({ kind: "message", text: "hi" }); // no marker → raw
|
|
32
|
+
const event = parseTranscriptEvent({ offset: 0, chunk });
|
|
33
|
+
assertEquals(event.kind, "stream-chunk");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("parseTranscriptEvent: a marker envelope with an unknown kind falls back to raw", () => {
|
|
37
|
+
const event = parseTranscriptEvent({ offset: 0, chunk: env("no-such-kind", { foo: 1 }) });
|
|
38
|
+
assertEquals(event.kind, "stream-chunk");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("parseTranscriptEvent: malformed JSON carrying the marker text falls back to raw", () => {
|
|
42
|
+
const event = parseTranscriptEvent({ offset: 0, chunk: `{"${TRANSCRIPT_EVENT_MARKER}":1, broken` });
|
|
43
|
+
assertEquals(event.kind, "stream-chunk");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("core vocab decodes message with role (default assistant)", () => {
|
|
47
|
+
assertEquals(parseTranscriptEvent({ offset: 1, chunk: env("message", { text: "hello" }) }), {
|
|
48
|
+
kind: "message",
|
|
49
|
+
offset: 1,
|
|
50
|
+
role: "assistant",
|
|
51
|
+
text: "hello",
|
|
52
|
+
});
|
|
53
|
+
assertEquals(parseTranscriptEvent({ offset: 2, chunk: env("message", { role: "user", text: "hi" }) }), {
|
|
54
|
+
kind: "message",
|
|
55
|
+
offset: 2,
|
|
56
|
+
role: "user",
|
|
57
|
+
text: "hi",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("core vocab: a message envelope missing text is rejected → raw fallback", () => {
|
|
62
|
+
assertEquals(parseTranscriptEvent({ offset: 0, chunk: env("message", { role: "user" }) }).kind, "stream-chunk");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("core vocab decodes tool-call / tool-result / turn / step / lifecycle", () => {
|
|
66
|
+
assertEquals(parseTranscriptEvent({ offset: 1, chunk: env("tool-call", { name: "grep", callId: "c1", args: { q: "x" } }) }), {
|
|
67
|
+
kind: "tool-call",
|
|
68
|
+
offset: 1,
|
|
69
|
+
name: "grep",
|
|
70
|
+
callId: "c1",
|
|
71
|
+
args: { q: "x" },
|
|
72
|
+
});
|
|
73
|
+
assertEquals(parseTranscriptEvent({ offset: 2, chunk: env("tool-result", { callId: "c1", ok: true, content: "found" }) }), {
|
|
74
|
+
kind: "tool-result",
|
|
75
|
+
offset: 2,
|
|
76
|
+
ok: true,
|
|
77
|
+
callId: "c1",
|
|
78
|
+
content: "found",
|
|
79
|
+
});
|
|
80
|
+
assertEquals(parseTranscriptEvent({ offset: 3, chunk: env("turn", { index: 4 }) }), { kind: "turn", offset: 3, index: 4 });
|
|
81
|
+
assertEquals(parseTranscriptEvent({ offset: 4, chunk: env("step", { label: "loop" }) }), { kind: "step", offset: 4, label: "loop" });
|
|
82
|
+
assertEquals(parseTranscriptEvent({ offset: 5, chunk: env("lifecycle", { phase: "completed" }) }), {
|
|
83
|
+
kind: "lifecycle",
|
|
84
|
+
offset: 5,
|
|
85
|
+
phase: "completed",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("mergeTranscriptVocab: adds a new kind without forking the parser, and can override a core one", () => {
|
|
90
|
+
const vocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
|
|
91
|
+
// A brand new merge-extensible kind, decoded into a message so deriveView still folds it.
|
|
92
|
+
reasoning: (body, offset) => ({ kind: "message", offset, role: "system", text: String(body.text ?? "") }),
|
|
93
|
+
});
|
|
94
|
+
const event = parseTranscriptEvent({ offset: 7, chunk: env("reasoning", { text: "thinking" }) }, vocab);
|
|
95
|
+
assertEquals(event, { kind: "message", offset: 7, role: "system", text: "thinking" });
|
|
96
|
+
// The core vocab is unchanged (merge returns a new object).
|
|
97
|
+
assertEquals(parseTranscriptEvent({ offset: 7, chunk: env("reasoning", { text: "thinking" }) }).kind, "stream-chunk");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("encodeTranscriptEvent round-trips every non-raw kind through the one parser", () => {
|
|
101
|
+
const events: TranscriptEvent[] = [
|
|
102
|
+
{ kind: "message", offset: 0, role: "assistant", text: "hi" },
|
|
103
|
+
{ kind: "tool-call", offset: 1, name: "ls", callId: "c1" },
|
|
104
|
+
{ kind: "tool-result", offset: 2, ok: false, callId: "c1", content: "boom" },
|
|
105
|
+
{ kind: "turn", offset: 3, index: 1 },
|
|
106
|
+
{ kind: "step", offset: 4, label: "s" },
|
|
107
|
+
{ kind: "lifecycle", offset: 5, phase: "exited" },
|
|
108
|
+
];
|
|
109
|
+
for (const original of events) {
|
|
110
|
+
const chunk = encodeTranscriptEvent(original);
|
|
111
|
+
assertEquals(parseTranscriptEvent({ offset: original.offset, chunk }), original);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("encodeTranscriptEvent returns raw bytes verbatim for a stream-chunk", () => {
|
|
116
|
+
assertEquals(encodeTranscriptEvent({ kind: "stream-chunk", offset: 0, chunk: "raw" }), "raw");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("deriveView: folds messages + tool cards into per-turn structure with lifecycle", () => {
|
|
120
|
+
const events: TranscriptEvent[] = [
|
|
121
|
+
{ kind: "turn", offset: 0, index: 0 },
|
|
122
|
+
{ kind: "message", offset: 1, role: "user", text: "do it" },
|
|
123
|
+
{ kind: "step", offset: 2 },
|
|
124
|
+
{ kind: "tool-call", offset: 3, name: "grep", callId: "c1" },
|
|
125
|
+
{ kind: "tool-result", offset: 4, ok: true, callId: "c1", content: "hit" },
|
|
126
|
+
{ kind: "message", offset: 5, role: "assistant", text: "done" },
|
|
127
|
+
{ kind: "turn", offset: 6, index: 1 },
|
|
128
|
+
{ kind: "message", offset: 7, role: "assistant", text: "next" },
|
|
129
|
+
{ kind: "stream-chunk", offset: 8, chunk: "raw-bytes" },
|
|
130
|
+
{ kind: "lifecycle", offset: 9, phase: "completed" },
|
|
131
|
+
];
|
|
132
|
+
const view = deriveView(events);
|
|
133
|
+
assertEquals(view.turns.length, 2);
|
|
134
|
+
assertEquals(view.turns[0]?.messages.map((m) => m.text), ["do it", "done"]);
|
|
135
|
+
assertEquals(view.turns[0]?.steps, 1);
|
|
136
|
+
assertEquals(view.turns[0]?.tools.length, 1);
|
|
137
|
+
assertEquals(view.turns[0]?.tools[0]?.result, { ok: true, offset: 4, content: "hit" });
|
|
138
|
+
assertEquals(view.turns[1]?.messages.map((m) => m.text), ["next"]);
|
|
139
|
+
assertEquals(view.messages.length, 3);
|
|
140
|
+
assertEquals(view.tools.length, 1);
|
|
141
|
+
assertEquals(view.lifecycle, "completed");
|
|
142
|
+
assertEquals(view.rawChunkCount, 1);
|
|
143
|
+
assertEquals(view.rawByteLength, Buffer.byteLength("raw-bytes", "utf8"));
|
|
144
|
+
assertEquals(view.eventCount, 10);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("deriveView: content before any turn event opens an implicit turn 0", () => {
|
|
148
|
+
const view = deriveView([
|
|
149
|
+
{ kind: "message", offset: 0, role: "assistant", text: "hello" },
|
|
150
|
+
{ kind: "tool-call", offset: 1, name: "ls" },
|
|
151
|
+
]);
|
|
152
|
+
assertEquals(view.turns.length, 1);
|
|
153
|
+
assertEquals(view.turns[0]?.index, 0);
|
|
154
|
+
assertEquals(view.turns[0]?.messages.length, 1);
|
|
155
|
+
assertEquals(view.turns[0]?.tools.length, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("deriveView: an anonymous tool-result pairs with the most recent open anonymous call", () => {
|
|
159
|
+
const view = deriveView([
|
|
160
|
+
{ kind: "tool-call", offset: 0, name: "a" },
|
|
161
|
+
{ kind: "tool-result", offset: 1, ok: false, content: "nope" },
|
|
162
|
+
]);
|
|
163
|
+
assertEquals(view.tools[0]?.result, { ok: false, offset: 1, content: "nope" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("deriveViewFromChunks: an all-raw log derives no structure but full raw fidelity accounting", () => {
|
|
167
|
+
const view = deriveViewFromChunks([
|
|
168
|
+
{ offset: 0, chunk: "line-1\n" },
|
|
169
|
+
{ offset: 1, chunk: "line-2\n" },
|
|
170
|
+
]);
|
|
171
|
+
assertEquals(view.turns.length, 0);
|
|
172
|
+
assertEquals(view.messages.length, 0);
|
|
173
|
+
assertEquals(view.rawChunkCount, 2);
|
|
174
|
+
assert(view.rawByteLength > 0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("deriveViewFromChunks: a mixed log derives typed structure while retaining raw chunks", () => {
|
|
178
|
+
const view = deriveViewFromChunks([
|
|
179
|
+
{ offset: 0, chunk: env("turn", { index: 0 }) },
|
|
180
|
+
{ offset: 1, chunk: "\u001b[2Jraw frame" },
|
|
181
|
+
{ offset: 2, chunk: env("message", { role: "assistant", text: "hi" }) },
|
|
182
|
+
]);
|
|
183
|
+
assertEquals(view.turns.length, 1);
|
|
184
|
+
assertEquals(view.messages.map((m) => m.text), ["hi"]);
|
|
185
|
+
assertEquals(view.rawChunkCount, 1);
|
|
186
|
+
});
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// nano-workforce — the transcript EVENT vocabulary + the single derive() fold (ADR 0056, #251).
|
|
2
|
+
//
|
|
3
|
+
// This is the "event-sourced session" layer over the H3 transcript store (#146/#222). The store is
|
|
4
|
+
// already append-only and offset-keyed — chunks are appended, never mutated — which is half of the
|
|
5
|
+
// dsh (DeepSeek Harness) event-sourced-session pattern. The gap it left is that chunks are opaque
|
|
6
|
+
// `TEXT`: every richer view (structured message history, tool cards, per-turn boundaries, token
|
|
7
|
+
// accounting) had to re-parse the raw frame bytes ad hoc, a DRIFT SURFACE (two parsers of the same
|
|
8
|
+
// bytes), which our "Derivation Over Duplication" doctrine forbids.
|
|
9
|
+
//
|
|
10
|
+
// This module closes that gap the way dsh does: the append-only log of TYPED events is the single
|
|
11
|
+
// source of truth, and every higher-level view is a DERIVATION of that one log via a single
|
|
12
|
+
// {@link deriveView} fold — "the log IS the state, so divergence is structurally impossible". A raw
|
|
13
|
+
// terminal chunk is retained verbatim as a `stream-chunk` event (byte-level replay fidelity is
|
|
14
|
+
// preserved); a producer that emits a structured, marker-tagged JSON envelope is decoded into the
|
|
15
|
+
// authoritative typed events (message / tool-call / tool-result / turn / step / lifecycle) the derived
|
|
16
|
+
// views fold over — mirroring dsh (raw chunks for token-replay, `assistant/message` authoritative).
|
|
17
|
+
//
|
|
18
|
+
// THE ONE PARSER. {@link parseTranscriptEvent} is the SINGLE place a stored chunk is classified into a
|
|
19
|
+
// typed event; every consumer (cockpit, search, token accounting, export) reads the derived view, not
|
|
20
|
+
// the raw bytes. A drift-guard test (`transcript-events.drift.test.ts`) asserts the event marker — and
|
|
21
|
+
// therefore the raw→event parse — appears in exactly this module, so a second parser cannot creep in.
|
|
22
|
+
//
|
|
23
|
+
// MERGE-EXTENSIBLE. The vocabulary is a small core ({@link CORE_TRANSCRIPT_VOCAB}) authors extend in the
|
|
24
|
+
// same schema with {@link mergeTranscriptVocab} (cribbed from dsh's merge-extensible event taxonomy and
|
|
25
|
+
// the S3 `mergeVocab`), so a new event kind is an additive merge, never a fork of the parser.
|
|
26
|
+
//
|
|
27
|
+
// Pure and side-effect-free: no I/O, unit-testable on Node, and it never touches the engine or a BPMN
|
|
28
|
+
// flow (ADR 0056: app-tier only, advisory).
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runtime-safe UTF-8 byte length. This module is imported by cockpit code that runs in the BROWSER
|
|
32
|
+
* (via `cockpit/transcript-derive.ts`), where Node's `Buffer` global is not available — a bare
|
|
33
|
+
* `Buffer.byteLength` would throw at runtime when deriving the view for a replayed transcript. Prefer
|
|
34
|
+
* `Buffer` when present (Node) and fall back to `TextEncoder` (a Web/Node standard) otherwise, so the
|
|
35
|
+
* single derive fold is portable across both hosts. This is the one canonical UTF-8 byte-length
|
|
36
|
+
* implementation the transcript plane derives from (reused by `transcript-read.ts`).
|
|
37
|
+
*/
|
|
38
|
+
let cachedTextEncoder: TextEncoder | undefined;
|
|
39
|
+
export function utf8ByteLength(text: string): number {
|
|
40
|
+
if (typeof Buffer !== "undefined") return Buffer.byteLength(text, "utf8");
|
|
41
|
+
// Cache one TextEncoder in the browser hot path (folding many stream-chunk events) to avoid
|
|
42
|
+
// allocating a new encoder — and the GC pressure it creates — on every call.
|
|
43
|
+
cachedTextEncoder ??= new TextEncoder();
|
|
44
|
+
return cachedTextEncoder.encode(text).length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The reserved marker field that distinguishes a structured transcript-event envelope from raw
|
|
49
|
+
* terminal bytes. A stored chunk is decoded as a typed event ONLY when it is a JSON object carrying
|
|
50
|
+
* this field set to the schema version — otherwise it is retained verbatim as a raw `stream-chunk`, so
|
|
51
|
+
* a raw ANSI frame that happens to be valid JSON is never mis-classified. Namespaced to nano-workforce
|
|
52
|
+
* so it cannot collide with a producer's own payload keys.
|
|
53
|
+
*/
|
|
54
|
+
export const TRANSCRIPT_EVENT_MARKER = "nwfTranscriptEvent" as const;
|
|
55
|
+
|
|
56
|
+
/** The current transcript-event envelope schema version (the value {@link TRANSCRIPT_EVENT_MARKER} carries). */
|
|
57
|
+
export const TRANSCRIPT_EVENT_VERSION = 1 as const;
|
|
58
|
+
|
|
59
|
+
/** The core, merge-extensible transcript-event kinds (authors add more via {@link mergeTranscriptVocab}). */
|
|
60
|
+
export type TranscriptEventKind =
|
|
61
|
+
| "stream-chunk"
|
|
62
|
+
| "message"
|
|
63
|
+
| "tool-call"
|
|
64
|
+
| "tool-result"
|
|
65
|
+
| "turn"
|
|
66
|
+
| "step"
|
|
67
|
+
| "lifecycle";
|
|
68
|
+
|
|
69
|
+
/** The message roles the derived history distinguishes (assistant is authoritative for derivation). */
|
|
70
|
+
export type TranscriptRole = "assistant" | "user" | "system" | "tool";
|
|
71
|
+
|
|
72
|
+
/** Fields every typed event carries: its kind and the store offset it was decoded from. */
|
|
73
|
+
interface TranscriptEventBase {
|
|
74
|
+
readonly offset: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A raw terminal chunk retained verbatim for byte-level replay fidelity (the default classification). */
|
|
78
|
+
export interface StreamChunkEvent extends TranscriptEventBase {
|
|
79
|
+
readonly kind: "stream-chunk";
|
|
80
|
+
/** The exact stored bytes — unmodified, so raw-byte replay stays faithful. */
|
|
81
|
+
readonly chunk: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An assistant/user/system message — authoritative for the derived message history. */
|
|
85
|
+
export interface MessageEvent extends TranscriptEventBase {
|
|
86
|
+
readonly kind: "message";
|
|
87
|
+
readonly role: TranscriptRole;
|
|
88
|
+
readonly text: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A tool invocation the agent issued. */
|
|
92
|
+
export interface ToolCallEvent extends TranscriptEventBase {
|
|
93
|
+
readonly kind: "tool-call";
|
|
94
|
+
readonly name: string;
|
|
95
|
+
/** A stable id linking this call to its {@link ToolResultEvent}, when the producer supplies one. */
|
|
96
|
+
readonly callId?: string;
|
|
97
|
+
readonly args?: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A tool result, paired back to its {@link ToolCallEvent} by `callId` (else the most recent open call). */
|
|
101
|
+
export interface ToolResultEvent extends TranscriptEventBase {
|
|
102
|
+
readonly kind: "tool-result";
|
|
103
|
+
readonly callId?: string;
|
|
104
|
+
readonly ok: boolean;
|
|
105
|
+
readonly content?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A turn boundary — the start of a new request/response cycle. */
|
|
109
|
+
export interface TurnEvent extends TranscriptEventBase {
|
|
110
|
+
readonly kind: "turn";
|
|
111
|
+
/** The producer's turn index, when supplied (else derived positionally). */
|
|
112
|
+
readonly index?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A step boundary within a turn (a tool loop iteration, a sub-agent hop, …). */
|
|
116
|
+
export interface StepEvent extends TranscriptEventBase {
|
|
117
|
+
readonly kind: "step";
|
|
118
|
+
readonly label?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A session lifecycle transition (open → completed, or an explicit exit). */
|
|
122
|
+
export interface LifecycleEvent extends TranscriptEventBase {
|
|
123
|
+
readonly kind: "lifecycle";
|
|
124
|
+
readonly phase: "open" | "completed" | "exited";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The core typed transcript-event union (merge-extensible: authors add kinds via the vocab). */
|
|
128
|
+
export type TranscriptEvent =
|
|
129
|
+
| StreamChunkEvent
|
|
130
|
+
| MessageEvent
|
|
131
|
+
| ToolCallEvent
|
|
132
|
+
| ToolResultEvent
|
|
133
|
+
| TurnEvent
|
|
134
|
+
| StepEvent
|
|
135
|
+
| LifecycleEvent;
|
|
136
|
+
|
|
137
|
+
/** A stored chunk as the store/read path exposes it (mirrors `TranscriptChunk`). */
|
|
138
|
+
export interface StoredChunk {
|
|
139
|
+
readonly offset: number;
|
|
140
|
+
readonly chunk: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A decoder for one event kind: given the parsed envelope body and the chunk offset, it returns the
|
|
145
|
+
* typed event (or `undefined` to reject a malformed envelope, which then falls back to `stream-chunk`).
|
|
146
|
+
* A vocabulary is the map kind → decoder; {@link mergeTranscriptVocab} extends it additively.
|
|
147
|
+
*/
|
|
148
|
+
export type TranscriptEventDecoder = (body: Record<string, unknown>, offset: number) => TranscriptEvent | undefined;
|
|
149
|
+
|
|
150
|
+
/** A transcript-event vocabulary: the ONE registry of kind → decoder the single parser consults. */
|
|
151
|
+
export type TranscriptVocab = Readonly<Record<string, TranscriptEventDecoder>>;
|
|
152
|
+
|
|
153
|
+
function str(body: Record<string, unknown>, key: string): string | undefined {
|
|
154
|
+
const v = body[key];
|
|
155
|
+
return typeof v === "string" ? v : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function num(body: Record<string, unknown>, key: string): number | undefined {
|
|
159
|
+
const v = body[key];
|
|
160
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ROLES: readonly TranscriptRole[] = ["assistant", "user", "system", "tool"];
|
|
164
|
+
|
|
165
|
+
/** Narrow an arbitrary string to a known {@link TranscriptRole}, defaulting to `assistant`. */
|
|
166
|
+
function toRole(value: string | undefined): TranscriptRole {
|
|
167
|
+
return ROLES.find((role) => role === value) ?? "assistant";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A structural guard: a non-null, non-array object is a plain record of unknown values. */
|
|
171
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
172
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The opinionated core vocabulary — the built-in event kinds every consumer understands out of the
|
|
177
|
+
* box. Authors extend it in the SAME schema via {@link mergeTranscriptVocab}; they never fork the
|
|
178
|
+
* parser. (`stream-chunk` is not decoded here — it is the fallback the parser applies to any chunk
|
|
179
|
+
* that is not a well-formed typed envelope, so raw fidelity needs no decoder.)
|
|
180
|
+
*/
|
|
181
|
+
export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
|
|
182
|
+
message: (body, offset) => {
|
|
183
|
+
const text = str(body, "text");
|
|
184
|
+
if (text === undefined) return undefined;
|
|
185
|
+
const roleRaw = str(body, "role");
|
|
186
|
+
return { kind: "message", offset, role: toRole(roleRaw), text };
|
|
187
|
+
},
|
|
188
|
+
"tool-call": (body, offset) => {
|
|
189
|
+
const name = str(body, "name");
|
|
190
|
+
if (name === undefined) return undefined;
|
|
191
|
+
const event: ToolCallEvent = { kind: "tool-call", offset, name };
|
|
192
|
+
const callId = str(body, "callId");
|
|
193
|
+
return {
|
|
194
|
+
...event,
|
|
195
|
+
...(callId !== undefined ? { callId } : {}),
|
|
196
|
+
...("args" in body ? { args: body.args } : {}),
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
"tool-result": (body, offset) => {
|
|
200
|
+
const ok = typeof body.ok === "boolean" ? body.ok : true;
|
|
201
|
+
const event: ToolResultEvent = { kind: "tool-result", offset, ok };
|
|
202
|
+
const callId = str(body, "callId");
|
|
203
|
+
const content = str(body, "content");
|
|
204
|
+
return {
|
|
205
|
+
...event,
|
|
206
|
+
...(callId !== undefined ? { callId } : {}),
|
|
207
|
+
...(content !== undefined ? { content } : {}),
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
turn: (body, offset) => {
|
|
211
|
+
const index = num(body, "index");
|
|
212
|
+
return index !== undefined ? { kind: "turn", offset, index } : { kind: "turn", offset };
|
|
213
|
+
},
|
|
214
|
+
step: (body, offset) => {
|
|
215
|
+
const label = str(body, "label");
|
|
216
|
+
return label !== undefined ? { kind: "step", offset, label } : { kind: "step", offset };
|
|
217
|
+
},
|
|
218
|
+
lifecycle: (body, offset) => {
|
|
219
|
+
const phase = str(body, "phase");
|
|
220
|
+
if (phase !== "open" && phase !== "completed" && phase !== "exited") return undefined;
|
|
221
|
+
return { kind: "lifecycle", offset, phase };
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Extend a vocabulary additively: later entries win on a key clash, so an author can either register a
|
|
227
|
+
* brand-new kind or deliberately override a core decoder. Returns a NEW frozen vocab — neither input is
|
|
228
|
+
* mutated — so the core stays canonical. (Cribbed from dsh's merge-extensible taxonomy / the S3
|
|
229
|
+
* `mergeVocab`: one schema, extended by merge, never a second parser.)
|
|
230
|
+
*/
|
|
231
|
+
export function mergeTranscriptVocab(base: TranscriptVocab, ...extensions: TranscriptVocab[]): TranscriptVocab {
|
|
232
|
+
return Object.freeze(Object.assign({}, base, ...extensions));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* THE ONE PARSER. Classify a single stored chunk into a typed {@link TranscriptEvent}.
|
|
237
|
+
*
|
|
238
|
+
* A chunk is decoded as a structured event ONLY when it is a JSON object carrying the
|
|
239
|
+
* {@link TRANSCRIPT_EVENT_MARKER} at the current version AND a `kind` the vocab knows AND its decoder
|
|
240
|
+
* accepts the body. Anything else — raw terminal bytes, non-JSON, a JSON value without the marker, an
|
|
241
|
+
* unknown kind, a decoder rejection — is retained verbatim as a `stream-chunk`, so byte-level replay
|
|
242
|
+
* fidelity is never lost. This is the SINGLE point at which raw bytes become typed events; every view
|
|
243
|
+
* folds over the result of this function, so there is exactly one parser of the log.
|
|
244
|
+
*/
|
|
245
|
+
export function parseTranscriptEvent(
|
|
246
|
+
entry: StoredChunk,
|
|
247
|
+
vocab: TranscriptVocab = CORE_TRANSCRIPT_VOCAB,
|
|
248
|
+
): TranscriptEvent {
|
|
249
|
+
const raw: StreamChunkEvent = { kind: "stream-chunk", offset: entry.offset, chunk: entry.chunk };
|
|
250
|
+
const body = decodeEnvelope(entry.chunk);
|
|
251
|
+
if (body === undefined) return raw;
|
|
252
|
+
const kind = typeof body.kind === "string" ? body.kind : undefined;
|
|
253
|
+
if (kind === undefined) return raw;
|
|
254
|
+
const decoder = vocab[kind];
|
|
255
|
+
if (decoder === undefined) return raw;
|
|
256
|
+
return decoder(body, entry.offset) ?? raw;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Decode a chunk into a marker-tagged envelope body, or `undefined` when it is not one. Kept private
|
|
261
|
+
* so `JSON.parse` of a chunk lives in exactly one place (the drift-guard depends on this).
|
|
262
|
+
*/
|
|
263
|
+
function decodeEnvelope(chunk: string): Record<string, unknown> | undefined {
|
|
264
|
+
// Cheap reject before the parse: a valid envelope is a JSON object mentioning the marker key.
|
|
265
|
+
const trimmed = chunk.trimStart();
|
|
266
|
+
if (!trimmed.startsWith("{") || !chunk.includes(TRANSCRIPT_EVENT_MARKER)) return undefined;
|
|
267
|
+
let parsed: unknown;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(chunk);
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
if (!isRecord(parsed)) return undefined;
|
|
274
|
+
return parsed[TRANSCRIPT_EVENT_MARKER] === TRANSCRIPT_EVENT_VERSION ? parsed : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Encode a typed event into the stored-chunk wire form a structured producer appends. The inverse of
|
|
279
|
+
* {@link parseTranscriptEvent} for every non-raw kind (a `stream-chunk` is stored as its own raw bytes,
|
|
280
|
+
* so it is returned verbatim). Provided so producers and tests speak the one envelope grammar rather
|
|
281
|
+
* than hand-rolling the marker — the derivation-over-duplication rule applied to the write side too.
|
|
282
|
+
*/
|
|
283
|
+
export function encodeTranscriptEvent(event: TranscriptEvent): string {
|
|
284
|
+
if (event.kind === "stream-chunk") return event.chunk;
|
|
285
|
+
const { offset: _offset, ...rest } = event;
|
|
286
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, ...rest });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A derived tool card: a tool-call paired with its result (result absent while the call is pending). */
|
|
290
|
+
export interface DerivedTool {
|
|
291
|
+
readonly name: string;
|
|
292
|
+
readonly callId?: string;
|
|
293
|
+
readonly args?: unknown;
|
|
294
|
+
readonly offset: number;
|
|
295
|
+
readonly result?: { readonly ok: boolean; readonly content?: string; readonly offset: number };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A derived message in the folded history. */
|
|
299
|
+
export interface DerivedMessage {
|
|
300
|
+
readonly role: TranscriptRole;
|
|
301
|
+
readonly text: string;
|
|
302
|
+
readonly offset: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A derived turn: the messages, tool cards and step count folded within one turn boundary. */
|
|
306
|
+
export interface DerivedTurn {
|
|
307
|
+
readonly index: number;
|
|
308
|
+
readonly startOffset: number;
|
|
309
|
+
readonly messages: readonly DerivedMessage[];
|
|
310
|
+
readonly tools: readonly DerivedTool[];
|
|
311
|
+
readonly steps: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The single derived view every higher-level consumer reads instead of re-parsing raw bytes. */
|
|
315
|
+
export interface DerivedView {
|
|
316
|
+
/** The per-turn structure (a turn is opened implicitly before the first turn event, if any content precedes it). */
|
|
317
|
+
readonly turns: readonly DerivedTurn[];
|
|
318
|
+
/** Every message across all turns, in offset order (the flat derived history). */
|
|
319
|
+
readonly messages: readonly DerivedMessage[];
|
|
320
|
+
/** Every tool card across all turns, in offset order. */
|
|
321
|
+
readonly tools: readonly DerivedTool[];
|
|
322
|
+
/** Total retained raw bytes (UTF-8) across `stream-chunk` events — the byte-replay fidelity accounting. */
|
|
323
|
+
readonly rawByteLength: number;
|
|
324
|
+
/** Number of retained raw chunks. */
|
|
325
|
+
readonly rawChunkCount: number;
|
|
326
|
+
/** The session lifecycle as the last lifecycle event reports it (defaults to `open`). */
|
|
327
|
+
readonly lifecycle: "open" | "completed" | "exited";
|
|
328
|
+
/** Number of typed events folded. */
|
|
329
|
+
readonly eventCount: number;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface MutableTurn {
|
|
333
|
+
index: number;
|
|
334
|
+
startOffset: number;
|
|
335
|
+
messages: DerivedMessage[];
|
|
336
|
+
tools: DerivedTool[];
|
|
337
|
+
steps: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* THE SINGLE FOLD. Derive every higher-level view from the typed event log — "the log IS the state".
|
|
342
|
+
*
|
|
343
|
+
* Folds the events (assumed in offset order — the store's append order) into per-turn structure, a flat
|
|
344
|
+
* message history, tool cards (each call paired to its result by `callId`, else the most recent open
|
|
345
|
+
* call), raw-byte accounting for replay fidelity, and the session lifecycle. It is a pure reduction of
|
|
346
|
+
* one log: the cockpit, search, token accounting and export all read THIS, so there is never a second
|
|
347
|
+
* parser of the same bytes. Content that precedes the first explicit `turn` event opens an implicit
|
|
348
|
+
* turn 0, so a producer that never emits turn boundaries still derives a coherent single-turn view.
|
|
349
|
+
*/
|
|
350
|
+
export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
351
|
+
const turns: MutableTurn[] = [];
|
|
352
|
+
const messages: DerivedMessage[] = [];
|
|
353
|
+
const tools: DerivedTool[] = [];
|
|
354
|
+
const openTools = new Map<string, DerivedTool>();
|
|
355
|
+
let anonymousTool: DerivedTool | undefined;
|
|
356
|
+
let rawByteLength = 0;
|
|
357
|
+
let rawChunkCount = 0;
|
|
358
|
+
let lifecycle: "open" | "completed" | "exited" = "open";
|
|
359
|
+
let eventCount = 0;
|
|
360
|
+
let current: MutableTurn | undefined;
|
|
361
|
+
|
|
362
|
+
const ensureTurn = (offset: number): MutableTurn => {
|
|
363
|
+
if (current === undefined) {
|
|
364
|
+
current = { index: turns.length, startOffset: offset, messages: [], tools: [], steps: 0 };
|
|
365
|
+
turns.push(current);
|
|
366
|
+
}
|
|
367
|
+
return current;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
for (const event of events) {
|
|
371
|
+
eventCount++;
|
|
372
|
+
switch (event.kind) {
|
|
373
|
+
case "turn": {
|
|
374
|
+
current = { index: event.index ?? turns.length, startOffset: event.offset, messages: [], tools: [], steps: 0 };
|
|
375
|
+
turns.push(current);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case "step": {
|
|
379
|
+
ensureTurn(event.offset).steps++;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case "message": {
|
|
383
|
+
const msg: DerivedMessage = { role: event.role, text: event.text, offset: event.offset };
|
|
384
|
+
messages.push(msg);
|
|
385
|
+
ensureTurn(event.offset).messages.push(msg);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "tool-call": {
|
|
389
|
+
const tool: DerivedTool = {
|
|
390
|
+
name: event.name,
|
|
391
|
+
offset: event.offset,
|
|
392
|
+
...(event.callId !== undefined ? { callId: event.callId } : {}),
|
|
393
|
+
...(event.args !== undefined ? { args: event.args } : {}),
|
|
394
|
+
};
|
|
395
|
+
tools.push(tool);
|
|
396
|
+
ensureTurn(event.offset).tools.push(tool);
|
|
397
|
+
if (event.callId !== undefined) openTools.set(event.callId, tool);
|
|
398
|
+
else anonymousTool = tool;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
case "tool-result": {
|
|
402
|
+
const target = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
|
|
403
|
+
if (target !== undefined) {
|
|
404
|
+
pairResult(tools, target, event);
|
|
405
|
+
pairResultInTurns(turns, target, event);
|
|
406
|
+
if (event.callId !== undefined) openTools.delete(event.callId);
|
|
407
|
+
else anonymousTool = undefined;
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case "lifecycle": {
|
|
412
|
+
lifecycle = event.phase;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "stream-chunk": {
|
|
416
|
+
rawByteLength += utf8ByteLength(event.chunk);
|
|
417
|
+
rawChunkCount++;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
turns: turns.map((t) => ({ index: t.index, startOffset: t.startOffset, messages: t.messages, tools: t.tools, steps: t.steps })),
|
|
425
|
+
messages,
|
|
426
|
+
tools,
|
|
427
|
+
rawByteLength,
|
|
428
|
+
rawChunkCount,
|
|
429
|
+
lifecycle,
|
|
430
|
+
eventCount,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Replace a pending tool with its result in the flat list. A pending tool starts as the same object in
|
|
435
|
+
* both the flat list and its turn (pushed by reference), so {@link pairResultInTurns} locates it there by
|
|
436
|
+
* identity; each list is then replaced independently with its own resolved copy via {@link withResult}. */
|
|
437
|
+
function pairResult(list: DerivedTool[], target: DerivedTool, result: ToolResultEvent): void {
|
|
438
|
+
const idx = list.indexOf(target);
|
|
439
|
+
if (idx >= 0) list[idx] = withResult(target, result);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Replace a pending tool with its result inside whichever turn holds it. */
|
|
443
|
+
function pairResultInTurns(turns: MutableTurn[], target: DerivedTool, result: ToolResultEvent): void {
|
|
444
|
+
for (const turn of turns) {
|
|
445
|
+
const idx = turn.tools.indexOf(target);
|
|
446
|
+
if (idx >= 0) {
|
|
447
|
+
turn.tools[idx] = withResult(target, result);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function withResult(tool: DerivedTool, result: ToolResultEvent): DerivedTool {
|
|
454
|
+
return {
|
|
455
|
+
...tool,
|
|
456
|
+
result: { ok: result.ok, offset: result.offset, ...(result.content !== undefined ? { content: result.content } : {}) },
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Convenience: parse a run of stored chunks into typed events through {@link parseTranscriptEvent} (the
|
|
462
|
+
* one parser) and fold them with {@link deriveView} in a single call — the entry point a consumer uses
|
|
463
|
+
* to go from stored bytes to a derived view without ever touching a second parser.
|
|
464
|
+
*/
|
|
465
|
+
export function deriveViewFromChunks(chunks: Iterable<StoredChunk>, vocab: TranscriptVocab = CORE_TRANSCRIPT_VOCAB): DerivedView {
|
|
466
|
+
function* parsed(): Generator<TranscriptEvent> {
|
|
467
|
+
for (const entry of chunks) yield parseTranscriptEvent(entry, vocab);
|
|
468
|
+
}
|
|
469
|
+
return deriveView(parsed());
|
|
470
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Unit tests for replay-by-reseed / fork of a transcript log (#251).
|
|
2
|
+
//
|
|
3
|
+
// Uses a real TranscriptStore over an in-memory node:sqlite db (the same double the relay-family suite
|
|
4
|
+
// uses), so the fork is exercised against the store's real idempotent, offset-keyed record/read path.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { type SqliteDb, TranscriptStore } from "@nanobpm/agentic/transcript";
|
|
8
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
9
|
+
import { forkTranscript, TranscriptForkError } from "./transcript-fork.ts";
|
|
10
|
+
|
|
11
|
+
/** An in-memory {@link SqliteDb} over `node:sqlite`, matching the store's exec/run/all surface. */
|
|
12
|
+
function memoryDb(): SqliteDb {
|
|
13
|
+
const raw = new DatabaseSync(":memory:");
|
|
14
|
+
return {
|
|
15
|
+
exec: (sql) => raw.exec(sql),
|
|
16
|
+
run: (sql, params = []) => raw.prepare(sql).run(...params),
|
|
17
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] => raw.prepare(sql).all(...params) as T[],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function seededStore(): TranscriptStore {
|
|
22
|
+
const store = new TranscriptStore(memoryDb());
|
|
23
|
+
store.ensureSchema();
|
|
24
|
+
return store;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Record chunks c0..c(n-1) into `stream` and complete it (an "exited" ephemeral session). */
|
|
28
|
+
function recordExited(store: TranscriptStore, stream: string, n: number): void {
|
|
29
|
+
const entries = Array.from({ length: n }, (_, i) => ({ offset: i, chunk: `c${i}` }));
|
|
30
|
+
store.record(stream, entries, "ephemeral");
|
|
31
|
+
// Complete via a flush of a ring that reports the whole window, marking the stream completed.
|
|
32
|
+
store.flush(stream, { since: () => ({ entries }), nextOffset: n }, "ephemeral");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("forkTranscript: seeds a new stream from the whole source log, offset-parity preserved", () => {
|
|
36
|
+
const store = seededStore();
|
|
37
|
+
recordExited(store, "job:src", 4);
|
|
38
|
+
|
|
39
|
+
const result = forkTranscript(store, "job:src", "fork:a");
|
|
40
|
+
assertEquals(result.seeded, 4);
|
|
41
|
+
assertEquals(result.throughOffset, 3);
|
|
42
|
+
assertEquals(result.stream, "fork:a");
|
|
43
|
+
// The fork replays the identical chunks at the identical offsets.
|
|
44
|
+
assertEquals(store.read("fork:a"), [
|
|
45
|
+
{ offset: 0, chunk: "c0" },
|
|
46
|
+
{ offset: 1, chunk: "c1" },
|
|
47
|
+
{ offset: 2, chunk: "c2" },
|
|
48
|
+
{ offset: 3, chunk: "c3" },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("forkTranscript: throughOffset seeds only the prefix up to (and including) N", () => {
|
|
53
|
+
const store = seededStore();
|
|
54
|
+
recordExited(store, "job:src", 5);
|
|
55
|
+
|
|
56
|
+
const result = forkTranscript(store, "job:src", "fork:b", { throughOffset: 2 });
|
|
57
|
+
assertEquals(result.seeded, 3);
|
|
58
|
+
assertEquals(result.throughOffset, 2);
|
|
59
|
+
assertEquals(
|
|
60
|
+
store.read("fork:b").map((c) => c.offset),
|
|
61
|
+
[0, 1, 2],
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("forkTranscript: the branch is independent — appending to the source never touches the fork", () => {
|
|
66
|
+
const store = seededStore();
|
|
67
|
+
recordExited(store, "job:src", 3);
|
|
68
|
+
forkTranscript(store, "job:src", "fork:c", { throughOffset: 1, lifecycle: "long-lived" });
|
|
69
|
+
|
|
70
|
+
// Continue the fork with a divergent chunk, and separately grow a long-lived source.
|
|
71
|
+
store.record("fork:c", [{ offset: 2, chunk: "branch-continuation" }], "long-lived");
|
|
72
|
+
assertEquals(
|
|
73
|
+
store.read("fork:c").map((c) => c.chunk),
|
|
74
|
+
["c0", "c1", "branch-continuation"],
|
|
75
|
+
);
|
|
76
|
+
// The source is untouched by the fork's divergence.
|
|
77
|
+
assertEquals(
|
|
78
|
+
store.read("job:src").map((c) => c.chunk),
|
|
79
|
+
["c0", "c1", "c2"],
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("forkTranscript: a fork replays through the SAME resume-from-offset (since) path as a native stream", () => {
|
|
84
|
+
const store = seededStore();
|
|
85
|
+
recordExited(store, "job:src", 4);
|
|
86
|
+
forkTranscript(store, "job:src", "fork:d");
|
|
87
|
+
|
|
88
|
+
const slice = store.since("fork:d", 2);
|
|
89
|
+
assertEquals(slice.gap, false);
|
|
90
|
+
assertEquals(
|
|
91
|
+
slice.entries.map((c) => c.offset),
|
|
92
|
+
[2, 3],
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("forkTranscript: throughOffset below the log yields an empty — but real, listed — fork", () => {
|
|
97
|
+
const store = seededStore();
|
|
98
|
+
recordExited(store, "job:src", 3);
|
|
99
|
+
|
|
100
|
+
const result = forkTranscript(store, "job:src", "fork:empty", { throughOffset: -1 });
|
|
101
|
+
assertEquals(result.seeded, 0);
|
|
102
|
+
assertEquals(result.throughOffset, undefined);
|
|
103
|
+
assert(store.get("fork:empty") !== undefined);
|
|
104
|
+
assertEquals(store.read("fork:empty"), []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("forkTranscript: refuses to fork a missing source", () => {
|
|
108
|
+
const store = seededStore();
|
|
109
|
+
assertThrows(() => forkTranscript(store, "job:nope", "fork:x"), TranscriptForkError, "no transcript");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("forkTranscript: refuses an existing target unless allowExisting is set", () => {
|
|
113
|
+
const store = seededStore();
|
|
114
|
+
recordExited(store, "job:src", 2);
|
|
115
|
+
forkTranscript(store, "job:src", "fork:e");
|
|
116
|
+
|
|
117
|
+
assertThrows(() => forkTranscript(store, "job:src", "fork:e"), TranscriptForkError, "already exists");
|
|
118
|
+
// With allowExisting the reseed is an idempotent no-op (offset-keyed record).
|
|
119
|
+
const again = forkTranscript(store, "job:src", "fork:e", { allowExisting: true });
|
|
120
|
+
assertEquals(again.seeded, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("forkTranscript: allowExisting refuses a target whose contents diverge from the reseed prefix", () => {
|
|
124
|
+
const store = seededStore();
|
|
125
|
+
recordExited(store, "job:src", 3);
|
|
126
|
+
// A pre-existing target that carries DIFFERENT bytes at an overlapping offset — reseeding here would
|
|
127
|
+
// leave an interleaved mixture (offset-keyed record silently no-ops the divergent offset).
|
|
128
|
+
store.record("fork:diverge", [{ offset: 0, chunk: "not-c0" }], "ephemeral");
|
|
129
|
+
assertThrows(
|
|
130
|
+
() => forkTranscript(store, "job:src", "fork:diverge", { allowExisting: true }),
|
|
131
|
+
TranscriptForkError,
|
|
132
|
+
"does not match the reseed prefix",
|
|
133
|
+
);
|
|
134
|
+
// The target is left untouched — no partial interleave.
|
|
135
|
+
assertEquals(
|
|
136
|
+
store.read("fork:diverge").map((c) => c.chunk),
|
|
137
|
+
["not-c0"],
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("forkTranscript: allowExisting refuses a target opened under a different lifecycle", () => {
|
|
142
|
+
const store = seededStore();
|
|
143
|
+
recordExited(store, "job:src", 2);
|
|
144
|
+
store.open("fork:lc", "long-lived");
|
|
145
|
+
assertThrows(
|
|
146
|
+
() => forkTranscript(store, "job:src", "fork:lc", { allowExisting: true }),
|
|
147
|
+
TranscriptForkError,
|
|
148
|
+
"cannot reseed",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("forkTranscript: refuses to fork a stream onto itself", () => {
|
|
153
|
+
const store = seededStore();
|
|
154
|
+
recordExited(store, "job:src", 1);
|
|
155
|
+
assertThrows(() => forkTranscript(store, "job:src", "job:src"), TranscriptForkError, "onto itself");
|
|
156
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// nano-workforce — replay-by-reseed / fork of a transcript log (ADR 0056, #251).
|
|
2
|
+
//
|
|
3
|
+
// The H3 read path (#222) can RESUME the same stream from an offset (reattach parity), but it cannot
|
|
4
|
+
// FORK: seed a NEW stream from an existing log so an exited agent's session can be branched or re-run
|
|
5
|
+
// from a chosen point ("what-if" a different continuation). dsh gets this for free because a session IS
|
|
6
|
+
// its append-only log, so forking is just re-seeding a new session from an existing log up to offset N.
|
|
7
|
+
// This module gives the transcript store the same capability WITHOUT touching the store package: it
|
|
8
|
+
// reads the source log and re-records it into a fresh stream through the store's own idempotent,
|
|
9
|
+
// offset-keyed {@link TranscriptStore.record} — so the fork is itself append-only and offset-parity
|
|
10
|
+
// with its source, and replays through the SAME resume-from-offset read path a native stream uses.
|
|
11
|
+
//
|
|
12
|
+
// Invariants preserved (ADR 0056): app-tier only, append-only (we only ever `record`, never mutate),
|
|
13
|
+
// advisory (a fork is a new advisory transcript — it gates no BPMN flow), and offset/resume wire-shape
|
|
14
|
+
// parity (the fork keeps the source offsets, so a reattach behaves identically on the branch).
|
|
15
|
+
|
|
16
|
+
import type { TranscriptChunk, TranscriptLifecycle, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
17
|
+
|
|
18
|
+
/** Raised when a fork/reseed cannot proceed — the source is missing, or the target already exists. */
|
|
19
|
+
export class TranscriptForkError extends Error {
|
|
20
|
+
readonly source: string;
|
|
21
|
+
readonly target: string;
|
|
22
|
+
constructor(source: string, target: string, message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "TranscriptForkError";
|
|
25
|
+
this.source = source;
|
|
26
|
+
this.target = target;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options controlling how a source log is reseeded into a new stream. */
|
|
31
|
+
export interface ForkTranscriptOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Seed only chunks with `offset <= throughOffset` (inclusive) — the point the branch diverges from.
|
|
34
|
+
* Omit to fork the WHOLE source log (every retained chunk). A `throughOffset` below the source's
|
|
35
|
+
* oldest retained offset yields an empty fork (a valid, if trivial, branch point).
|
|
36
|
+
*/
|
|
37
|
+
readonly throughOffset?: number;
|
|
38
|
+
/**
|
|
39
|
+
* The forked stream's retention lifecycle. Defaults to `ephemeral` — a fork is a captured branch,
|
|
40
|
+
* retained-whole then swept like any completed session, not a growing live stream.
|
|
41
|
+
*/
|
|
42
|
+
readonly lifecycle?: TranscriptLifecycle;
|
|
43
|
+
/**
|
|
44
|
+
* Allow reseeding into a target that already exists. Off by default: forking onto a populated stream
|
|
45
|
+
* would interleave two logs' bytes and defeat offset-parity, so we refuse rather than clobber. When
|
|
46
|
+
* on, seeding is still idempotent (offset-keyed), so re-running the SAME fork is a safe no-op — but
|
|
47
|
+
* the existing target must already hold exactly this seed prefix (same offsets, same chunk bytes,
|
|
48
|
+
* same lifecycle); a target that diverges from the prefix throws rather than silently interleaving.
|
|
49
|
+
*/
|
|
50
|
+
readonly allowExisting?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The outcome of a {@link forkTranscript}: the new stream, how many chunks it seeded, and its window. */
|
|
54
|
+
export interface ForkResult {
|
|
55
|
+
/** The forked stream id (the `target` argument). */
|
|
56
|
+
readonly stream: string;
|
|
57
|
+
/** The source stream the fork was seeded from. */
|
|
58
|
+
readonly source: string;
|
|
59
|
+
/** Number of chunks newly persisted into the fork. */
|
|
60
|
+
readonly seeded: number;
|
|
61
|
+
/** The highest source offset included in the fork (undefined when the fork is empty). */
|
|
62
|
+
readonly throughOffset?: number;
|
|
63
|
+
/** The forked stream's metadata after seeding. */
|
|
64
|
+
readonly meta: TranscriptStream;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fork a transcript: seed a NEW stream (`target`) from an existing log (`source`) up to a chosen offset,
|
|
69
|
+
* so an exited session can be branched and replayed independently.
|
|
70
|
+
*
|
|
71
|
+
* The fork keeps the SOURCE offsets (offset-parity), so it resumes through the identical
|
|
72
|
+
* resume-from-offset read path a native stream uses. It reads the source's retained window
|
|
73
|
+
* (`store.read`), takes the prefix at or below `throughOffset` (default: the whole log), and re-records
|
|
74
|
+
* it into `target` via the store's idempotent offset-keyed `record` — so the operation is append-only
|
|
75
|
+
* and safe to re-run. The branch is fully independent of its source thereafter: appending to either
|
|
76
|
+
* stream never affects the other.
|
|
77
|
+
*
|
|
78
|
+
* Throws {@link TranscriptForkError} when the source has no transcript, when the target already
|
|
79
|
+
* exists and `allowExisting` is not set, or when `allowExisting` is set but the existing target does
|
|
80
|
+
* not already match the reseed prefix exactly (divergent chunk bytes/offsets or a different lifecycle).
|
|
81
|
+
*/
|
|
82
|
+
export function forkTranscript(
|
|
83
|
+
store: TranscriptStore,
|
|
84
|
+
source: string,
|
|
85
|
+
target: string,
|
|
86
|
+
options: ForkTranscriptOptions = {},
|
|
87
|
+
): ForkResult {
|
|
88
|
+
if (source === target) {
|
|
89
|
+
throw new TranscriptForkError(source, target, "cannot fork a stream onto itself");
|
|
90
|
+
}
|
|
91
|
+
if (store.get(source) === undefined) {
|
|
92
|
+
throw new TranscriptForkError(source, target, `source stream "${source}" has no transcript to fork`);
|
|
93
|
+
}
|
|
94
|
+
const existing = store.get(target);
|
|
95
|
+
if (existing !== undefined && !options.allowExisting) {
|
|
96
|
+
throw new TranscriptForkError(source, target, `target stream "${target}" already exists (pass allowExisting to reseed it)`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const lifecycle: TranscriptLifecycle = options.lifecycle ?? "ephemeral";
|
|
100
|
+
const through = options.throughOffset;
|
|
101
|
+
const chunks: TranscriptChunk[] = store
|
|
102
|
+
.read(source)
|
|
103
|
+
.filter((c) => through === undefined || c.offset <= through);
|
|
104
|
+
|
|
105
|
+
// Reseeding onto an EXISTING target (allowExisting) is only safe when that target already holds
|
|
106
|
+
// exactly the prefix we are about to seed. `record()` is offset-keyed and idempotent, so it silently
|
|
107
|
+
// no-ops any offset already present — if the existing chunk at that offset differs (or the target
|
|
108
|
+
// carries offsets outside this prefix, or a different lifecycle), the reseed would leave a stream
|
|
109
|
+
// that is a MIXTURE of the prior data and the seed, breaking the documented offset-parity invariant.
|
|
110
|
+
// Validate the overlap before writing and refuse rather than clobber/interleave.
|
|
111
|
+
if (existing !== undefined) {
|
|
112
|
+
if (existing.lifecycle !== lifecycle) {
|
|
113
|
+
throw new TranscriptForkError(
|
|
114
|
+
source,
|
|
115
|
+
target,
|
|
116
|
+
`target stream "${target}" already exists with lifecycle "${existing.lifecycle}", cannot reseed as "${lifecycle}"`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const seedByOffset = new Map(chunks.map((c) => [c.offset, c.chunk]));
|
|
120
|
+
for (const c of store.read(target)) {
|
|
121
|
+
const expected = seedByOffset.get(c.offset);
|
|
122
|
+
if (expected === undefined || expected !== c.chunk) {
|
|
123
|
+
throw new TranscriptForkError(
|
|
124
|
+
source,
|
|
125
|
+
target,
|
|
126
|
+
`target stream "${target}" already contains data that does not match the reseed prefix at offset ${c.offset}; refusing to interleave`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Open the fork explicitly so an empty fork (throughOffset predating the log) is still a real,
|
|
133
|
+
// listed stream under its own lifecycle rather than a phantom — mirrors the store's open-then-record.
|
|
134
|
+
store.open(target, lifecycle);
|
|
135
|
+
const seeded = chunks.length > 0 ? store.record(target, chunks, lifecycle) : 0;
|
|
136
|
+
|
|
137
|
+
const meta = store.get(target);
|
|
138
|
+
if (meta === undefined) {
|
|
139
|
+
// Defensive: open() above guarantees a row, so this only fires on a store contract breach.
|
|
140
|
+
throw new TranscriptForkError(source, target, `fork of "${source}" into "${target}" did not persist a stream`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result: ForkResult = {
|
|
144
|
+
stream: target,
|
|
145
|
+
source,
|
|
146
|
+
seeded,
|
|
147
|
+
meta,
|
|
148
|
+
};
|
|
149
|
+
const last = chunks.at(-1);
|
|
150
|
+
return last !== undefined ? { ...result, throughOffset: last.offset } : result;
|
|
151
|
+
}
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
18
18
|
import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
|
|
19
19
|
import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
|
|
20
|
+
import { utf8ByteLength } from "./transcript-events.ts";
|
|
20
21
|
|
|
21
22
|
/** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
|
|
22
23
|
export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
|
|
23
24
|
let total = 0;
|
|
24
|
-
for (const c of chunks) total +=
|
|
25
|
+
for (const c of chunks) total += utf8ByteLength(c.chunk);
|
|
25
26
|
return total;
|
|
26
27
|
}
|
|
27
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|