@nanobpm/agentic 0.6.0 → 0.8.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/README.md +58 -1
- package/dist/cockpit/boot.d.ts +11 -1
- package/dist/cockpit/boot.js +28 -1
- package/dist/cockpit/index.d.ts +3 -2
- package/dist/cockpit/index.js +1 -0
- package/dist/cockpit/structured-view.d.ts +41 -0
- package/dist/cockpit/structured-view.js +106 -0
- package/dist/cockpit/terminal-session.d.ts +37 -1
- package/dist/cockpit/terminal-session.js +27 -3
- package/dist/transcript/events.d.ts +213 -0
- package/dist/transcript/events.js +322 -0
- package/dist/transcript/index.d.ts +8 -0
- package/dist/transcript/index.js +7 -0
- package/package.json +1 -1
- package/src/cockpit/boot.test.ts +114 -0
- package/src/cockpit/boot.ts +41 -2
- package/src/cockpit/index.ts +8 -0
- package/src/cockpit/structured-view.test.ts +70 -0
- package/src/cockpit/structured-view.ts +114 -0
- package/src/cockpit/terminal-session.test.ts +95 -2
- package/src/cockpit/terminal-session.ts +65 -4
- package/src/transcript/events.drift.test.ts +68 -0
- package/src/transcript/events.test.ts +308 -0
- package/src/transcript/events.ts +490 -0
- package/src/transcript/index.ts +38 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The **Nano agentic protocol** (ADR 0056): one app-tier channel carrying agent pr
|
|
|
13
13
|
| `@nanobpm/agentic/vocab` | Vocab resolver + core vocabulary (S3) |
|
|
14
14
|
| `@nanobpm/agentic/demand` | Demand×supply model (S4) |
|
|
15
15
|
| `@nanobpm/agentic/relay` | Relay ring + QoS scheduler (S5) |
|
|
16
|
-
| `@nanobpm/agentic/transcript` | Transcript store, retention-by-lifecycle (S6) + turn-structured view (Camunda `AgentHistoryRecordValue` parity) |
|
|
16
|
+
| `@nanobpm/agentic/transcript` | Transcript store, retention-by-lifecycle (S6) + turn-structured view (Camunda `AgentHistoryRecordValue` parity) + the typed transcript-event **vocabulary** & single `parseTranscriptEvent` fold |
|
|
17
17
|
| `@nanobpm/agentic/blackboard` | Blackboard channel family (S7) |
|
|
18
18
|
| `@nanobpm/agentic/cockpit` | Operator visibility page — the cockpit (S8) |
|
|
19
19
|
| `@nanobpm/agentic/session` | Canonical `SessionEvent` + authoritative session log for durable agent-session resume (ADR 0062) |
|
|
@@ -21,3 +21,60 @@ The **Nano agentic protocol** (ADR 0056): one app-tier channel carrying agent pr
|
|
|
21
21
|
The barrel `@nanobpm/agentic` re-exports each family as a namespace (`protocol`, `channel`, …). The worker-side client ships separately as `@nanobpm/urban-agent-client`.
|
|
22
22
|
|
|
23
23
|
The wire contract is the single source of truth; nothing here rides the Camunda-8 engine or its transport.
|
|
24
|
+
|
|
25
|
+
## Transcript event vocabulary (`@nanobpm/agentic/transcript`)
|
|
26
|
+
|
|
27
|
+
The transcript subpath now ships the canonical typed transcript-event **vocabulary** alongside the S6
|
|
28
|
+
store, so every Urban app derives ACP-rich transcripts from **one** parser instead of forking its own:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import {
|
|
32
|
+
parseTranscriptEvent, // THE ONE PARSER: stored chunk → typed TranscriptEvent
|
|
33
|
+
deriveView, // THE ONE FOLD: typed events → per-turn structured view
|
|
34
|
+
deriveViewFromChunks, // parse + fold in one call
|
|
35
|
+
mergeTranscriptVocab, // additive EXTENSION POINT (register a new kind, never fork)
|
|
36
|
+
CORE_TRANSCRIPT_VOCAB,
|
|
37
|
+
encodeTranscriptEvent,
|
|
38
|
+
utf8ByteLength, // browser-safe (TextEncoder, no Buffer)
|
|
39
|
+
TRANSCRIPT_EVENT_MARKER, // "nwfTranscriptEvent" — canonical single source of truth
|
|
40
|
+
TRANSCRIPT_EVENT_VERSION, // 1
|
|
41
|
+
} from "@nanobpm/agentic/transcript";
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The vocab/parser modules are **browser-safe** (no `Buffer`, no Node-only imports), so the cockpit derive
|
|
45
|
+
runs in-browser. Kinds: `message`, `tool-call`, `tool-result`, `turn`, `step`, `lifecycle`, plus the
|
|
46
|
+
raw `stream-chunk` fallback that preserves byte-level replay fidelity. A stored chunk is decoded as a
|
|
47
|
+
structured event **only** when it is a JSON object carrying `TRANSCRIPT_EVENT_MARKER` at the current
|
|
48
|
+
version — otherwise it is retained verbatim, so a raw ANSI frame that happens to be JSON is never
|
|
49
|
+
mis-classified. `TRANSCRIPT_EVENT_MARKER` + `parseTranscriptEvent` are the canonical detection surface
|
|
50
|
+
the whole package family (e.g. the cockpit's structured-stream drill-in) imports from here — never a
|
|
51
|
+
private copy.
|
|
52
|
+
|
|
53
|
+
### Extending the vocabulary (merge, don't fork)
|
|
54
|
+
|
|
55
|
+
A downstream app adds its own kind + parse handler **without editing this package**:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const appVocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
|
|
59
|
+
permission: (body, offset) =>
|
|
60
|
+
typeof body.requestId === "string"
|
|
61
|
+
? { kind: "message", offset, role: "system", text: `permission(${body.requestId})` }
|
|
62
|
+
: undefined, // reject malformed → raw fallback
|
|
63
|
+
});
|
|
64
|
+
parseTranscriptEvent({ offset, chunk }, appVocab); // the one parser, now aware of `permission`
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Migration for nano-workforce
|
|
68
|
+
|
|
69
|
+
`nano-workforce` currently forks this vocabulary at `app/agentic/transcript-events.ts`. To consume the
|
|
70
|
+
shared copy:
|
|
71
|
+
|
|
72
|
+
1. Replace imports of the local `./transcript-events.ts` with `@nanobpm/agentic/transcript`
|
|
73
|
+
(`parseTranscriptEvent`, `deriveView`, `mergeTranscriptVocab`, `TRANSCRIPT_EVENT_MARKER`,
|
|
74
|
+
`TRANSCRIPT_EVENT_VERSION`, `utf8ByteLength`, the event types, …). The public API — symbol names,
|
|
75
|
+
the `"nwfTranscriptEvent"` marker string, and version `1` — is preserved verbatim, so this is a
|
|
76
|
+
drop-in.
|
|
77
|
+
2. Register nano-workforce's app-specific `permission` kind (nano-workforce#559) via
|
|
78
|
+
`mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, { permission: … })` instead of editing a forked parser.
|
|
79
|
+
3. **Delete** `app/agentic/transcript-events.ts` (and fold its `transcript-events.drift.test.ts` marker
|
|
80
|
+
guard into the shared package's guard, already ported here as `events.drift.test.ts`).
|
package/dist/cockpit/boot.d.ts
CHANGED
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
import type { DemandSupplyReport } from "../demand/index.ts";
|
|
21
21
|
import { type Scheduler, type SocketFactory } from "./relay-client.ts";
|
|
22
22
|
import { type DocumentLike, type ElementLike } from "./render.ts";
|
|
23
|
-
import { type TerminalSink } from "./terminal-session.ts";
|
|
23
|
+
import { type StructuredSink, type TerminalSink } from "./terminal-session.ts";
|
|
24
24
|
/** Mounts a terminal into `host` and returns the sink relay output is written to. */
|
|
25
25
|
export type CreateTerminal = (host: ElementLike) => TerminalSink;
|
|
26
|
+
/** Mounts a structured (ACP) view into `host` and returns the sink decoded transcript events are routed to. */
|
|
27
|
+
export type CreateStructured = (host: ElementLike) => StructuredSink;
|
|
26
28
|
/** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
|
|
27
29
|
export type TimerHandle = unknown;
|
|
28
30
|
export interface CockpitEnv {
|
|
@@ -36,6 +38,14 @@ export interface CockpitEnv {
|
|
|
36
38
|
readonly connectRelay: SocketFactory;
|
|
37
39
|
/** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
|
|
38
40
|
readonly createTerminal: CreateTerminal;
|
|
41
|
+
/**
|
|
42
|
+
* Mounts the structured (ACP) view widget and returns its event sink. Optional:
|
|
43
|
+
* when omitted the drill-in uses the built-in {@link createStructuredSink} DOM
|
|
44
|
+
* renderer over {@link doc}, so marker-tagged chunks are decoded and routed to
|
|
45
|
+
* that structured surface while raw bytes still flow to the {@link createTerminal}
|
|
46
|
+
* sink. Provide your own to override the built-in renderer.
|
|
47
|
+
*/
|
|
48
|
+
readonly createStructured?: CreateStructured;
|
|
39
49
|
/** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
|
|
40
50
|
readonly schedule?: Scheduler;
|
|
41
51
|
/** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must be paired with {@link clearTimer}. */
|
package/dist/cockpit/boot.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isPosInt } from "../relay/index.js";
|
|
2
2
|
import { RelayChannelClient } from "./relay-client.js";
|
|
3
3
|
import { renderCockpit } from "./render.js";
|
|
4
|
+
import { createStructuredSink } from "./structured-view.js";
|
|
4
5
|
import { TerminalSession } from "./terminal-session.js";
|
|
5
6
|
import { cockpitView } from "./view.js";
|
|
6
7
|
const DEFAULT_REFRESH_MS = 2000;
|
|
@@ -8,6 +9,8 @@ class Cockpit {
|
|
|
8
9
|
#env;
|
|
9
10
|
#matrixRegion;
|
|
10
11
|
#terminalHost;
|
|
12
|
+
#structuredHost;
|
|
13
|
+
#createStructured;
|
|
11
14
|
#refreshMs;
|
|
12
15
|
#setTimer;
|
|
13
16
|
#clearTimer;
|
|
@@ -20,6 +23,9 @@ class Cockpit {
|
|
|
20
23
|
// The currently mounted terminal, tracked so switching streams (and dispose)
|
|
21
24
|
// tears down the prior xterm instance instead of leaking it + its listeners.
|
|
22
25
|
#terminal;
|
|
26
|
+
// The currently mounted structured view, torn down alongside #terminal so a
|
|
27
|
+
// stream switch / dispose never leaks the prior worker's structured widget.
|
|
28
|
+
#structured;
|
|
23
29
|
// Bumped by every start()/stop() so an in-flight #tick() from a previous
|
|
24
30
|
// start cycle can't reschedule after a stop→start race and leave two
|
|
25
31
|
// overlapping poll chains running against the same cockpit.
|
|
@@ -64,6 +70,10 @@ class Cockpit {
|
|
|
64
70
|
this.#timeouts.delete(handle);
|
|
65
71
|
}
|
|
66
72
|
});
|
|
73
|
+
// The structured (ACP) view mounter defaults to the built-in DOM renderer over
|
|
74
|
+
// the injected document, so a structured stream derives + renders without any
|
|
75
|
+
// extra wiring; a browser caller may override it (e.g. a richer widget).
|
|
76
|
+
this.#createStructured = env.createStructured ?? ((host) => createStructuredSink(host, env.doc));
|
|
67
77
|
// Build the stable skeleton once: a volatile matrix region the poll
|
|
68
78
|
// re-renders, and a PERSISTENT terminal region a refresh never touches.
|
|
69
79
|
env.host.replaceChildren();
|
|
@@ -81,6 +91,14 @@ class Cockpit {
|
|
|
81
91
|
this.#terminalHost.className = "cockpit-terminal-host";
|
|
82
92
|
this.#terminalHost.setAttribute("data-terminal", "host");
|
|
83
93
|
terminalPanel.appendChild(this.#terminalHost);
|
|
94
|
+
// A sibling PERSISTENT region for the derived structured (ACP) view. A raw
|
|
95
|
+
// stream keeps it in its initial/empty structured state; a structured stream
|
|
96
|
+
// renders here instead of dumping JSON into the byte-terminal; a mixed stream
|
|
97
|
+
// feeds both.
|
|
98
|
+
this.#structuredHost = env.doc.createElement("div");
|
|
99
|
+
this.#structuredHost.className = "cockpit-structured-host";
|
|
100
|
+
this.#structuredHost.setAttribute("data-structured", "host");
|
|
101
|
+
terminalPanel.appendChild(this.#structuredHost);
|
|
84
102
|
shell.appendChild(this.#matrixRegion);
|
|
85
103
|
shell.appendChild(terminalPanel);
|
|
86
104
|
env.host.appendChild(shell);
|
|
@@ -157,11 +175,17 @@ class Cockpit {
|
|
|
157
175
|
// still cleans it up on the next drill or on dispose().
|
|
158
176
|
this.#terminal?.dispose?.();
|
|
159
177
|
this.#terminal = undefined;
|
|
178
|
+
// The structured view is torn down in lockstep with the terminal.
|
|
179
|
+
this.#structured?.dispose?.();
|
|
180
|
+
this.#structured = undefined;
|
|
160
181
|
try {
|
|
161
|
-
// Fresh terminal for the newly selected worker.
|
|
182
|
+
// Fresh terminal + structured view for the newly selected worker.
|
|
162
183
|
this.#terminalHost.replaceChildren();
|
|
163
184
|
const sink = this.#env.createTerminal(this.#terminalHost);
|
|
164
185
|
this.#terminal = sink;
|
|
186
|
+
this.#structuredHost.replaceChildren();
|
|
187
|
+
const structured = this.#createStructured(this.#structuredHost);
|
|
188
|
+
this.#structured = structured;
|
|
165
189
|
let session;
|
|
166
190
|
const client = new RelayChannelClient({
|
|
167
191
|
connect: this.#env.connectRelay,
|
|
@@ -175,6 +199,7 @@ class Cockpit {
|
|
|
175
199
|
session = new TerminalSession({
|
|
176
200
|
stream,
|
|
177
201
|
sink,
|
|
202
|
+
structured,
|
|
178
203
|
send: (message) => client.sendRelay(message),
|
|
179
204
|
credit: this.#env.credit,
|
|
180
205
|
});
|
|
@@ -194,6 +219,8 @@ class Cockpit {
|
|
|
194
219
|
this.#drill = undefined;
|
|
195
220
|
this.#terminal?.dispose?.();
|
|
196
221
|
this.#terminal = undefined;
|
|
222
|
+
this.#structured?.dispose?.();
|
|
223
|
+
this.#structured = undefined;
|
|
197
224
|
}
|
|
198
225
|
}
|
|
199
226
|
/** Boot the cockpit against an injected environment. Call {@link CockpitHandle.start} to poll. */
|
package/dist/cockpit/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* Nothing here rides the Camunda-8 engine or its transport.
|
|
22
22
|
*/
|
|
23
23
|
export { cockpitView, type CockpitLight, type CockpitNetworkRow, type CockpitTokenRow, type CockpitView, type SloStatus, } from "./view.ts";
|
|
24
|
-
export { TerminalSession, type RelayInbound, type RelayOutbound, type RelaySend, type TerminalSessionOptions, type TerminalSink, } from "./terminal-session.ts";
|
|
24
|
+
export { TerminalSession, type RelayInbound, type RelayOutbound, type RelaySend, type StructuredSink, type TerminalSessionOptions, type TerminalSink, } from "./terminal-session.ts";
|
|
25
25
|
export { RelayChannelClient, type RawSocket, type RelayChannelClientOptions, type Scheduler, type SocketFactory, } from "./relay-client.ts";
|
|
26
26
|
export { renderCockpit, type CockpitDom, type DocumentLike, type ElementLike, type RenderOptions, } from "./render.ts";
|
|
27
|
-
export {
|
|
27
|
+
export { createStructuredSink, renderStructured, type StructuredTerminal, } from "./structured-view.ts";
|
|
28
|
+
export { bootCockpit, type CockpitEnv, type CockpitHandle, type CreateStructured, type CreateTerminal, type TimerHandle, } from "./boot.ts";
|
package/dist/cockpit/index.js
CHANGED
|
@@ -24,4 +24,5 @@ export { cockpitView, } from "./view.js";
|
|
|
24
24
|
export { TerminalSession, } from "./terminal-session.js";
|
|
25
25
|
export { RelayChannelClient, } from "./relay-client.js";
|
|
26
26
|
export { renderCockpit, } from "./render.js";
|
|
27
|
+
export { createStructuredSink, renderStructured, } from "./structured-view.js";
|
|
27
28
|
export { bootCockpit, } from "./boot.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cockpit's structured-stream renderer — S8's drill-in for an ACP stream.
|
|
3
|
+
*
|
|
4
|
+
* When a drilled worker's relay stream is a **structured** ACP stream (its chunks
|
|
5
|
+
* are {@link TRANSCRIPT_EVENT_MARKER}-tagged transcript-event envelopes rather than
|
|
6
|
+
* raw PTY bytes), the {@link TerminalSession} routes each decoded event here instead
|
|
7
|
+
* of the byte-terminal. This renderer does **not** re-parse or pretty-print JSON: it
|
|
8
|
+
* feeds the accumulated typed events straight through the ONE canonical
|
|
9
|
+
* {@link deriveView} fold from `@nanobpm/agentic/transcript` and renders the resulting
|
|
10
|
+
* {@link DerivedView} (turns → messages + tool cards) into the DOM.
|
|
11
|
+
*
|
|
12
|
+
* Like {@link renderCockpit} it builds against the structural {@link ElementLike} /
|
|
13
|
+
* {@link DocumentLike} subset (not lib.dom), so it renders identically embedded and
|
|
14
|
+
* standalone, is unit-tested on Node with the in-memory fake and no `as` cast, and is
|
|
15
|
+
* browser-safe — it relies only on the browser-safe transcript vocab (no `Buffer`).
|
|
16
|
+
*
|
|
17
|
+
* Events arrive offset-keyed and immutable in offset order, so re-deriving the whole
|
|
18
|
+
* (idempotent) log on each event is correct across a resume-from-offset reconnect: a
|
|
19
|
+
* replayed chunk below the resume point never reaches this sink, so no event is
|
|
20
|
+
* dropped or double-applied.
|
|
21
|
+
*/
|
|
22
|
+
import { type DerivedView } from "../transcript/index.ts";
|
|
23
|
+
import type { DocumentLike, ElementLike } from "./render.ts";
|
|
24
|
+
import type { StructuredSink } from "./terminal-session.ts";
|
|
25
|
+
/**
|
|
26
|
+
* Render a derived structured view into `host`, replacing whatever was there.
|
|
27
|
+
* Idempotent: re-call it with the latest {@link DerivedView} on every new event.
|
|
28
|
+
*/
|
|
29
|
+
export declare function renderStructured(host: ElementLike, doc: DocumentLike, view: DerivedView): void;
|
|
30
|
+
/** A structured sink with a `dispose` teardown (mirrors {@link TerminalSink}). */
|
|
31
|
+
export interface StructuredTerminal extends StructuredSink {
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a {@link StructuredSink} that accumulates the offset-ordered transcript
|
|
36
|
+
* events a structured stream delivers, folds them through the canonical
|
|
37
|
+
* {@link deriveView}, and renders the derived view into `host` on each event. The
|
|
38
|
+
* accumulated log is this sink's own state, so constructing one per drill-in gives
|
|
39
|
+
* each worker its own structured view.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createStructuredSink(host: ElementLike, doc: DocumentLike): StructuredTerminal;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cockpit's structured-stream renderer — S8's drill-in for an ACP stream.
|
|
3
|
+
*
|
|
4
|
+
* When a drilled worker's relay stream is a **structured** ACP stream (its chunks
|
|
5
|
+
* are {@link TRANSCRIPT_EVENT_MARKER}-tagged transcript-event envelopes rather than
|
|
6
|
+
* raw PTY bytes), the {@link TerminalSession} routes each decoded event here instead
|
|
7
|
+
* of the byte-terminal. This renderer does **not** re-parse or pretty-print JSON: it
|
|
8
|
+
* feeds the accumulated typed events straight through the ONE canonical
|
|
9
|
+
* {@link deriveView} fold from `@nanobpm/agentic/transcript` and renders the resulting
|
|
10
|
+
* {@link DerivedView} (turns → messages + tool cards) into the DOM.
|
|
11
|
+
*
|
|
12
|
+
* Like {@link renderCockpit} it builds against the structural {@link ElementLike} /
|
|
13
|
+
* {@link DocumentLike} subset (not lib.dom), so it renders identically embedded and
|
|
14
|
+
* standalone, is unit-tested on Node with the in-memory fake and no `as` cast, and is
|
|
15
|
+
* browser-safe — it relies only on the browser-safe transcript vocab (no `Buffer`).
|
|
16
|
+
*
|
|
17
|
+
* Events arrive offset-keyed and immutable in offset order, so re-deriving the whole
|
|
18
|
+
* (idempotent) log on each event is correct across a resume-from-offset reconnect: a
|
|
19
|
+
* replayed chunk below the resume point never reaches this sink, so no event is
|
|
20
|
+
* dropped or double-applied.
|
|
21
|
+
*/
|
|
22
|
+
import { deriveView } from "../transcript/index.js";
|
|
23
|
+
function el(doc, tag, className, text) {
|
|
24
|
+
const node = doc.createElement(tag);
|
|
25
|
+
if (className !== undefined)
|
|
26
|
+
node.className = className;
|
|
27
|
+
if (text !== undefined)
|
|
28
|
+
node.textContent = text;
|
|
29
|
+
return node;
|
|
30
|
+
}
|
|
31
|
+
function toolCard(doc, tool) {
|
|
32
|
+
const card = el(doc, "div", "cockpit-structured-tool");
|
|
33
|
+
card.setAttribute("data-tool", tool.name);
|
|
34
|
+
card.setAttribute("data-offset", String(tool.offset));
|
|
35
|
+
card.setAttribute("data-state", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
36
|
+
const head = el(doc, "div", "cockpit-structured-tool-head");
|
|
37
|
+
head.appendChild(el(doc, "span", "cockpit-structured-tool-name", tool.name));
|
|
38
|
+
if (tool.callId !== undefined)
|
|
39
|
+
head.appendChild(el(doc, "span", "cockpit-structured-tool-id", tool.callId));
|
|
40
|
+
card.appendChild(head);
|
|
41
|
+
if (tool.args !== undefined) {
|
|
42
|
+
card.appendChild(el(doc, "pre", "cockpit-structured-tool-args", JSON.stringify(tool.args)));
|
|
43
|
+
}
|
|
44
|
+
if (tool.result !== undefined) {
|
|
45
|
+
const result = el(doc, "div", "cockpit-structured-tool-result");
|
|
46
|
+
result.setAttribute("data-ok", tool.result.ok ? "true" : "false");
|
|
47
|
+
if (tool.result.content !== undefined)
|
|
48
|
+
result.textContent = tool.result.content;
|
|
49
|
+
card.appendChild(result);
|
|
50
|
+
}
|
|
51
|
+
return card;
|
|
52
|
+
}
|
|
53
|
+
function turnSection(doc, turn) {
|
|
54
|
+
const section = el(doc, "section", "cockpit-structured-turn");
|
|
55
|
+
section.setAttribute("data-turn", String(turn.index));
|
|
56
|
+
section.setAttribute("data-steps", String(turn.steps));
|
|
57
|
+
for (const message of turn.messages) {
|
|
58
|
+
const row = el(doc, "div", "cockpit-structured-message");
|
|
59
|
+
row.setAttribute("data-role", message.role);
|
|
60
|
+
row.setAttribute("data-offset", String(message.offset));
|
|
61
|
+
row.appendChild(el(doc, "span", "cockpit-structured-role", message.role));
|
|
62
|
+
row.appendChild(el(doc, "span", "cockpit-structured-text", message.text));
|
|
63
|
+
section.appendChild(row);
|
|
64
|
+
}
|
|
65
|
+
for (const tool of turn.tools) {
|
|
66
|
+
section.appendChild(toolCard(doc, tool));
|
|
67
|
+
}
|
|
68
|
+
return section;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Render a derived structured view into `host`, replacing whatever was there.
|
|
72
|
+
* Idempotent: re-call it with the latest {@link DerivedView} on every new event.
|
|
73
|
+
*/
|
|
74
|
+
export function renderStructured(host, doc, view) {
|
|
75
|
+
host.replaceChildren();
|
|
76
|
+
const root = el(doc, "div", "cockpit-structured");
|
|
77
|
+
root.setAttribute("data-lifecycle", view.lifecycle);
|
|
78
|
+
root.setAttribute("data-events", String(view.eventCount));
|
|
79
|
+
for (const turn of view.turns) {
|
|
80
|
+
root.appendChild(turnSection(doc, turn));
|
|
81
|
+
}
|
|
82
|
+
host.appendChild(root);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build a {@link StructuredSink} that accumulates the offset-ordered transcript
|
|
86
|
+
* events a structured stream delivers, folds them through the canonical
|
|
87
|
+
* {@link deriveView}, and renders the derived view into `host` on each event. The
|
|
88
|
+
* accumulated log is this sink's own state, so constructing one per drill-in gives
|
|
89
|
+
* each worker its own structured view.
|
|
90
|
+
*/
|
|
91
|
+
export function createStructuredSink(host, doc) {
|
|
92
|
+
const events = [];
|
|
93
|
+
// Render an empty derived view up-front so the structured region is present and
|
|
94
|
+
// consistent before the first event lands.
|
|
95
|
+
renderStructured(host, doc, deriveView(events));
|
|
96
|
+
return {
|
|
97
|
+
event(event) {
|
|
98
|
+
events.push(event);
|
|
99
|
+
renderStructured(host, doc, deriveView(events));
|
|
100
|
+
},
|
|
101
|
+
dispose() {
|
|
102
|
+
events.length = 0;
|
|
103
|
+
host.replaceChildren();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -19,8 +19,24 @@
|
|
|
19
19
|
* - inbound `{ op: "subscribed", stream, gap, nextOffset }` — the resume ack
|
|
20
20
|
* (`gap: boolean` — the S5 wire flags whether chunks aged out),
|
|
21
21
|
* - inbound {@link RelayPayload} `{ stream, offset, chunk }` — a data chunk.
|
|
22
|
+
*
|
|
23
|
+
* ## Structured (ACP) vs. raw streams
|
|
24
|
+
*
|
|
25
|
+
* A relay stream is either a **raw** byte stream (PTY output — arbitrary bytes) or
|
|
26
|
+
* a **structured** ACP stream whose chunks are {@link TRANSCRIPT_EVENT_MARKER}-tagged
|
|
27
|
+
* JSON envelopes (the transcript-event vocabulary) riding the *same*
|
|
28
|
+
* `{ stream, offset, chunk }` frames. The session classifies **each chunk** through
|
|
29
|
+
* the one canonical {@link parseTranscriptEvent} — detection is on the marker tag,
|
|
30
|
+
* never a guess — and routes a decoded structured event to the {@link StructuredSink}
|
|
31
|
+
* (the derived structured renderer) while writing a raw chunk verbatim to the byte
|
|
32
|
+
* {@link TerminalSink}. A mixed stream that starts raw and only later carries tagged
|
|
33
|
+
* chunks is handled per-chunk, so each chunk lands on the right surface. Routing does
|
|
34
|
+
* not touch the resume machinery: `nextOffset` advances identically whichever surface
|
|
35
|
+
* a chunk is applied to, so resume-from-offset neither loses nor double-applies
|
|
36
|
+
* structured events across a reconnect exactly as for raw output.
|
|
22
37
|
*/
|
|
23
38
|
import type { RelayPayload } from "../protocol/index.ts";
|
|
39
|
+
import { type TranscriptEvent } from "../transcript/index.ts";
|
|
24
40
|
/** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
|
|
25
41
|
export interface TerminalSink {
|
|
26
42
|
/** Append a chunk of terminal output. */
|
|
@@ -28,6 +44,19 @@ export interface TerminalSink {
|
|
|
28
44
|
/** Tear down the underlying terminal widget and its listeners, if any. */
|
|
29
45
|
dispose?(): void;
|
|
30
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The structured sink a session routes decoded transcript events to when the stream
|
|
49
|
+
* is a structured ACP stream (marker-tagged chunks). It receives the offset-keyed,
|
|
50
|
+
* immutable {@link TranscriptEvent} the one canonical {@link parseTranscriptEvent}
|
|
51
|
+
* derived from the chunk — never raw JSON — so the derived structured renderer folds
|
|
52
|
+
* over typed events rather than pretty-printing bytes.
|
|
53
|
+
*/
|
|
54
|
+
export interface StructuredSink {
|
|
55
|
+
/** Apply one decoded structured transcript event (in offset order). */
|
|
56
|
+
event(event: TranscriptEvent): void;
|
|
57
|
+
/** Tear down the underlying structured widget and its listeners, if any. */
|
|
58
|
+
dispose?(): void;
|
|
59
|
+
}
|
|
31
60
|
/** An outbound relay message the session asks its transport to send. */
|
|
32
61
|
export type RelayOutbound = {
|
|
33
62
|
readonly op: "subscribe";
|
|
@@ -50,8 +79,15 @@ export type RelaySend = (message: RelayOutbound) => void;
|
|
|
50
79
|
export interface TerminalSessionOptions {
|
|
51
80
|
/** The relay stream id (one worker's terminal). */
|
|
52
81
|
readonly stream: string;
|
|
53
|
-
/** Where decoded output is written. */
|
|
82
|
+
/** Where decoded raw output is written. */
|
|
54
83
|
readonly sink: TerminalSink;
|
|
84
|
+
/**
|
|
85
|
+
* Where decoded structured transcript events are routed when the stream is a
|
|
86
|
+
* structured ACP stream (marker-tagged chunks). Omit for a pure byte-terminal:
|
|
87
|
+
* without it every chunk — even a marker-tagged one — is written verbatim to
|
|
88
|
+
* {@link sink}, preserving the legacy raw-only behaviour.
|
|
89
|
+
*/
|
|
90
|
+
readonly structured?: StructuredSink;
|
|
55
91
|
/** Emits outbound relay messages. */
|
|
56
92
|
readonly send: RelaySend;
|
|
57
93
|
/** Bulk credit requested on each (re)subscribe. Default 1024. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.js";
|
|
2
|
+
import { parseTranscriptEvent } from "../transcript/index.js";
|
|
2
3
|
const DEFAULT_CREDIT = 1024;
|
|
3
4
|
function isRelayData(message) {
|
|
4
5
|
return !("op" in message);
|
|
@@ -11,6 +12,7 @@ function isRelayData(message) {
|
|
|
11
12
|
export class TerminalSession {
|
|
12
13
|
#stream;
|
|
13
14
|
#sink;
|
|
15
|
+
#structured;
|
|
14
16
|
#send;
|
|
15
17
|
#credit;
|
|
16
18
|
#onGap;
|
|
@@ -21,6 +23,7 @@ export class TerminalSession {
|
|
|
21
23
|
constructor(options) {
|
|
22
24
|
this.#stream = options.stream;
|
|
23
25
|
this.#sink = options.sink;
|
|
26
|
+
this.#structured = options.structured;
|
|
24
27
|
this.#send = options.send;
|
|
25
28
|
this.#credit = options.credit ?? DEFAULT_CREDIT;
|
|
26
29
|
if (!isPosInt(this.#credit)) {
|
|
@@ -84,18 +87,39 @@ export class TerminalSession {
|
|
|
84
87
|
// re-deliver the boundary chunk; anything we have already applied is dropped.
|
|
85
88
|
if (data.offset < this.#nextOffset)
|
|
86
89
|
return;
|
|
87
|
-
// Compute the next resume point BEFORE
|
|
90
|
+
// Compute the next resume point BEFORE applying so the apply is atomic: a
|
|
88
91
|
// chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
|
|
89
|
-
// before we touch
|
|
92
|
+
// before we touch either sink — otherwise the chunk is applied but #nextOffset
|
|
90
93
|
// is not advanced, leaving a partially-applied state that re-delivers (and
|
|
91
94
|
// so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
|
|
92
95
|
// fast rather than overflowing into an unsafe nextOffset — that value would
|
|
93
96
|
// later be echoed in subscribe.from and lose precision on any JSON
|
|
94
97
|
// round-trip, silently corrupting resume semantics.
|
|
95
98
|
const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
|
|
96
|
-
this.#
|
|
99
|
+
this.#apply(data);
|
|
97
100
|
this.#nextOffset = nextOffset;
|
|
98
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Route one already-de-duplicated data chunk to the right surface. Classify it
|
|
104
|
+
* through the ONE canonical {@link parseTranscriptEvent}: a marker-tagged, known
|
|
105
|
+
* envelope decodes to a typed event and (when a {@link StructuredSink} is wired)
|
|
106
|
+
* is routed there — the derived structured renderer — instead of dumping raw JSON
|
|
107
|
+
* into the byte-terminal; anything else (raw bytes, non-JSON, an untagged or
|
|
108
|
+
* malformed envelope) falls back to `stream-chunk` and is written verbatim to the
|
|
109
|
+
* byte {@link TerminalSink}, so a legacy/raw or mixed stream renders exactly as
|
|
110
|
+
* before. With no structured sink the parse is skipped entirely, keeping the
|
|
111
|
+
* raw-only path byte-identical to a pure byte-terminal.
|
|
112
|
+
*/
|
|
113
|
+
#apply(data) {
|
|
114
|
+
if (this.#structured !== undefined) {
|
|
115
|
+
const event = parseTranscriptEvent({ offset: data.offset, chunk: data.chunk });
|
|
116
|
+
if (event.kind !== "stream-chunk") {
|
|
117
|
+
this.#structured.event(event);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
this.#sink.write(data.chunk);
|
|
122
|
+
}
|
|
99
123
|
#onSubscribed(gap, nextOffset) {
|
|
100
124
|
// Validate the ack's resume point exactly as the constructor validates
|
|
101
125
|
// `from`: `nextOffset` is echoed back into subscribe.from on the next
|