@nanobpm/agentic 0.7.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/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/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/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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/agentic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The Nano agentic protocol (ADR 0056): one app-tier channel carrying agent presence/registry, demand×supply, a shared blackboard and live terminal relay — with the wire contract, channel/hub, family modules and the operator cockpit, as subpath exports.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/cockpit/boot.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
|
|
4
4
|
import { decodeFrame, encodeFrame, type Frame } from "../protocol/index.ts";
|
|
5
5
|
import type { DemandSupplyReport } from "../demand/index.ts";
|
|
6
|
+
import { type TranscriptEvent, encodeTranscriptEvent } from "../transcript/index.ts";
|
|
6
7
|
|
|
7
8
|
import { bootCockpit, type CockpitEnv } from "./boot.ts";
|
|
8
9
|
import { FakeDocument, FakeElement } from "./fake-dom.ts";
|
|
@@ -64,8 +65,11 @@ interface Rig {
|
|
|
64
65
|
readonly host: FakeElement;
|
|
65
66
|
readonly sockets: FakeSocket[];
|
|
66
67
|
readonly terminalWrites: string[];
|
|
68
|
+
readonly structuredEvents: TranscriptEvent[];
|
|
67
69
|
terminalMounts: number;
|
|
68
70
|
terminalDisposes: number;
|
|
71
|
+
structuredMounts: number;
|
|
72
|
+
structuredDisposes: number;
|
|
69
73
|
readonly timers: Array<{ run: () => void; ms: number }>;
|
|
70
74
|
reconnect: (() => void) | undefined;
|
|
71
75
|
report: DemandSupplyReport;
|
|
@@ -76,13 +80,17 @@ function rig(): Rig {
|
|
|
76
80
|
const host = new FakeElement("body");
|
|
77
81
|
const sockets: FakeSocket[] = [];
|
|
78
82
|
const terminalWrites: string[] = [];
|
|
83
|
+
const structuredEvents: TranscriptEvent[] = [];
|
|
79
84
|
const timers: Array<{ run: () => void; ms: number }> = [];
|
|
80
85
|
const state: Rig = {
|
|
81
86
|
host,
|
|
82
87
|
sockets,
|
|
83
88
|
terminalWrites,
|
|
89
|
+
structuredEvents,
|
|
84
90
|
terminalMounts: 0,
|
|
85
91
|
terminalDisposes: 0,
|
|
92
|
+
structuredMounts: 0,
|
|
93
|
+
structuredDisposes: 0,
|
|
86
94
|
timers,
|
|
87
95
|
reconnect: undefined,
|
|
88
96
|
report: served,
|
|
@@ -106,6 +114,16 @@ function rig(): Rig {
|
|
|
106
114
|
},
|
|
107
115
|
};
|
|
108
116
|
},
|
|
117
|
+
createStructured: (structuredHost) => {
|
|
118
|
+
state.structuredMounts += 1;
|
|
119
|
+
structuredHost.appendChild(new FakeElement("div"));
|
|
120
|
+
return {
|
|
121
|
+
event: (event) => structuredEvents.push(event),
|
|
122
|
+
dispose: () => {
|
|
123
|
+
state.structuredDisposes += 1;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
109
127
|
schedule: (run) => {
|
|
110
128
|
state.reconnect = run;
|
|
111
129
|
},
|
|
@@ -172,6 +190,102 @@ test("relay output is written to the drilled worker's terminal", async () => {
|
|
|
172
190
|
r.sockets[0]?.fireOpen();
|
|
173
191
|
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "boot\n" } });
|
|
174
192
|
assert.deepEqual(r.terminalWrites, ["boot\n"]);
|
|
193
|
+
assert.deepEqual(r.structuredEvents, [], "a raw stream routes nothing to the structured view");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function structuredFrame(seq: number, offset: number, event: TranscriptEvent): Frame {
|
|
197
|
+
return { lane: "bulk", family: "relay", seq, payload: { stream: "ci-a", offset, chunk: encodeTranscriptEvent(event) } };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
test("a structured (marker-tagged) stream routes to the structured view, NOT the byte-terminal", async () => {
|
|
201
|
+
const r = rig();
|
|
202
|
+
const cockpit = bootCockpit(r.env);
|
|
203
|
+
await cockpit.refresh();
|
|
204
|
+
cockpit.drill("ci-a");
|
|
205
|
+
r.sockets[0]?.fireOpen();
|
|
206
|
+
r.sockets[0]?.deliver(structuredFrame(0, 0, { kind: "turn", offset: 0, index: 0 }));
|
|
207
|
+
r.sockets[0]?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "hi" }));
|
|
208
|
+
assert.deepEqual(r.terminalWrites, [], "structured chunks are not dumped into the byte-terminal");
|
|
209
|
+
assert.deepEqual(
|
|
210
|
+
r.structuredEvents.map((e) => e.kind),
|
|
211
|
+
["turn", "message"],
|
|
212
|
+
"the decoded transcript events reach the structured view",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a mixed stream routes each chunk to the right surface (raw → terminal, tagged → structured)", async () => {
|
|
217
|
+
const r = rig();
|
|
218
|
+
const cockpit = bootCockpit(r.env);
|
|
219
|
+
await cockpit.refresh();
|
|
220
|
+
cockpit.drill("ci-a");
|
|
221
|
+
r.sockets[0]?.fireOpen();
|
|
222
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "booting\n" } });
|
|
223
|
+
r.sockets[0]?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "ready" }));
|
|
224
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 2, payload: { stream: "ci-a", offset: 2, chunk: "tail\n" } });
|
|
225
|
+
assert.deepEqual(r.terminalWrites, ["booting\n", "tail\n"]);
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
r.structuredEvents.map((e) => (e.kind === "message" ? e.text : e.kind)),
|
|
228
|
+
["ready"],
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("the structured view survives a cockpit reconnect — resume-from-offset, no loss, no dup", async () => {
|
|
233
|
+
const r = rig();
|
|
234
|
+
const cockpit = bootCockpit(r.env);
|
|
235
|
+
await cockpit.refresh();
|
|
236
|
+
cockpit.drill("ci-a");
|
|
237
|
+
|
|
238
|
+
const s1 = r.sockets[0];
|
|
239
|
+
s1?.fireOpen();
|
|
240
|
+
s1?.deliver(structuredFrame(0, 0, { kind: "message", offset: 0, role: "assistant", text: "a" }));
|
|
241
|
+
s1?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "b" }));
|
|
242
|
+
|
|
243
|
+
// The cockpit's socket drops; the client reconnects.
|
|
244
|
+
s1?.fireClose();
|
|
245
|
+
assert.ok(r.reconnect !== undefined, "a reconnect was scheduled");
|
|
246
|
+
r.reconnect?.();
|
|
247
|
+
const s2 = r.sockets[1];
|
|
248
|
+
s2?.fireOpen(); // re-attach → resume from offset 2
|
|
249
|
+
const subs = s2?.subscribeFrames() ?? [];
|
|
250
|
+
assert.deepEqual(subs.at(-1)?.payload, { op: "subscribe", stream: "ci-a", from: 2, credit: 1024 });
|
|
251
|
+
|
|
252
|
+
// The hub replays the retained tail (re-sends offset 1) then continues.
|
|
253
|
+
s2?.deliver(structuredFrame(0, 1, { kind: "message", offset: 1, role: "assistant", text: "b" }));
|
|
254
|
+
s2?.deliver(structuredFrame(1, 2, { kind: "message", offset: 2, role: "assistant", text: "c" }));
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
r.structuredEvents.map((e) => (e.kind === "message" ? e.text : e.kind)),
|
|
257
|
+
["a", "b", "c"],
|
|
258
|
+
"no dropped and no duplicated structured events across the reconnect",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("the built-in structured renderer derives into the structured host when none is injected", async () => {
|
|
263
|
+
const r = rig();
|
|
264
|
+
// Drop the custom createStructured so the default DOM renderer is exercised end-to-end.
|
|
265
|
+
const { createStructured: _drop, ...envWithoutStructured } = r.env;
|
|
266
|
+
const cockpit = bootCockpit(envWithoutStructured);
|
|
267
|
+
await cockpit.refresh();
|
|
268
|
+
cockpit.drill("ci-a");
|
|
269
|
+
r.sockets[0]?.fireOpen();
|
|
270
|
+
r.sockets[0]?.deliver(structuredFrame(0, 0, { kind: "message", offset: 0, role: "assistant", text: "hello" }));
|
|
271
|
+
assert.deepEqual(r.terminalWrites, [], "structured chunk is not dumped as raw");
|
|
272
|
+
const rendered = r.host.byClass("cockpit-structured-message");
|
|
273
|
+
assert.equal(rendered.length, 1, "the built-in structured renderer rendered the derived message");
|
|
274
|
+
assert.match(rendered[0]?.text() ?? "", /hello/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("switching streams disposes the prior structured view", async () => {
|
|
278
|
+
const r = rig();
|
|
279
|
+
const cockpit = bootCockpit(r.env);
|
|
280
|
+
await cockpit.refresh();
|
|
281
|
+
cockpit.drill("ci-a");
|
|
282
|
+
assert.equal(r.structuredMounts, 1);
|
|
283
|
+
assert.equal(r.structuredDisposes, 0);
|
|
284
|
+
cockpit.drill("ci-b");
|
|
285
|
+
assert.equal(r.structuredMounts, 2);
|
|
286
|
+
assert.equal(r.structuredDisposes, 1, "prior structured view disposed on stream switch");
|
|
287
|
+
cockpit.dispose();
|
|
288
|
+
assert.equal(r.structuredDisposes, 2, "the live structured view is disposed on dispose()");
|
|
175
289
|
});
|
|
176
290
|
|
|
177
291
|
test("the terminal survives a matrix refresh — it is not re-mounted and keeps streaming", async () => {
|
package/src/cockpit/boot.ts
CHANGED
|
@@ -21,12 +21,16 @@ import type { DemandSupplyReport } from "../demand/index.ts";
|
|
|
21
21
|
import { isPosInt } from "../relay/index.ts";
|
|
22
22
|
import { RelayChannelClient, type Scheduler, type SocketFactory } from "./relay-client.ts";
|
|
23
23
|
import { type DocumentLike, type ElementLike, renderCockpit } from "./render.ts";
|
|
24
|
-
import {
|
|
24
|
+
import { createStructuredSink } from "./structured-view.ts";
|
|
25
|
+
import { type StructuredSink, TerminalSession, type TerminalSink } from "./terminal-session.ts";
|
|
25
26
|
import { cockpitView } from "./view.ts";
|
|
26
27
|
|
|
27
28
|
/** Mounts a terminal into `host` and returns the sink relay output is written to. */
|
|
28
29
|
export type CreateTerminal = (host: ElementLike) => TerminalSink;
|
|
29
30
|
|
|
31
|
+
/** Mounts a structured (ACP) view into `host` and returns the sink decoded transcript events are routed to. */
|
|
32
|
+
export type CreateStructured = (host: ElementLike) => StructuredSink;
|
|
33
|
+
|
|
30
34
|
/** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
|
|
31
35
|
export type TimerHandle = unknown;
|
|
32
36
|
|
|
@@ -41,6 +45,14 @@ export interface CockpitEnv {
|
|
|
41
45
|
readonly connectRelay: SocketFactory;
|
|
42
46
|
/** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
|
|
43
47
|
readonly createTerminal: CreateTerminal;
|
|
48
|
+
/**
|
|
49
|
+
* Mounts the structured (ACP) view widget and returns its event sink. Optional:
|
|
50
|
+
* when omitted the drill-in uses the built-in {@link createStructuredSink} DOM
|
|
51
|
+
* renderer over {@link doc}, so marker-tagged chunks are decoded and routed to
|
|
52
|
+
* that structured surface while raw bytes still flow to the {@link createTerminal}
|
|
53
|
+
* sink. Provide your own to override the built-in renderer.
|
|
54
|
+
*/
|
|
55
|
+
readonly createStructured?: CreateStructured;
|
|
44
56
|
/** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
|
|
45
57
|
readonly schedule?: Scheduler;
|
|
46
58
|
/** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must be paired with {@link clearTimer}. */
|
|
@@ -82,6 +94,8 @@ class Cockpit implements CockpitHandle {
|
|
|
82
94
|
readonly #env: CockpitEnv;
|
|
83
95
|
readonly #matrixRegion: ElementLike;
|
|
84
96
|
readonly #terminalHost: ElementLike;
|
|
97
|
+
readonly #structuredHost: ElementLike;
|
|
98
|
+
readonly #createStructured: CreateStructured;
|
|
85
99
|
readonly #refreshMs: number;
|
|
86
100
|
readonly #setTimer: (run: () => void, ms: number) => TimerHandle;
|
|
87
101
|
readonly #clearTimer: (handle: TimerHandle) => void;
|
|
@@ -94,6 +108,9 @@ class Cockpit implements CockpitHandle {
|
|
|
94
108
|
// The currently mounted terminal, tracked so switching streams (and dispose)
|
|
95
109
|
// tears down the prior xterm instance instead of leaking it + its listeners.
|
|
96
110
|
#terminal: TerminalSink | undefined;
|
|
111
|
+
// The currently mounted structured view, torn down alongside #terminal so a
|
|
112
|
+
// stream switch / dispose never leaks the prior worker's structured widget.
|
|
113
|
+
#structured: StructuredSink | undefined;
|
|
97
114
|
// Bumped by every start()/stop() so an in-flight #tick() from a previous
|
|
98
115
|
// start cycle can't reschedule after a stop→start race and leave two
|
|
99
116
|
// overlapping poll chains running against the same cockpit.
|
|
@@ -142,6 +159,11 @@ class Cockpit implements CockpitHandle {
|
|
|
142
159
|
}
|
|
143
160
|
});
|
|
144
161
|
|
|
162
|
+
// The structured (ACP) view mounter defaults to the built-in DOM renderer over
|
|
163
|
+
// the injected document, so a structured stream derives + renders without any
|
|
164
|
+
// extra wiring; a browser caller may override it (e.g. a richer widget).
|
|
165
|
+
this.#createStructured = env.createStructured ?? ((host) => createStructuredSink(host, env.doc));
|
|
166
|
+
|
|
145
167
|
// Build the stable skeleton once: a volatile matrix region the poll
|
|
146
168
|
// re-renders, and a PERSISTENT terminal region a refresh never touches.
|
|
147
169
|
env.host.replaceChildren();
|
|
@@ -159,6 +181,14 @@ class Cockpit implements CockpitHandle {
|
|
|
159
181
|
this.#terminalHost.className = "cockpit-terminal-host";
|
|
160
182
|
this.#terminalHost.setAttribute("data-terminal", "host");
|
|
161
183
|
terminalPanel.appendChild(this.#terminalHost);
|
|
184
|
+
// A sibling PERSISTENT region for the derived structured (ACP) view. A raw
|
|
185
|
+
// stream keeps it in its initial/empty structured state; a structured stream
|
|
186
|
+
// renders here instead of dumping JSON into the byte-terminal; a mixed stream
|
|
187
|
+
// feeds both.
|
|
188
|
+
this.#structuredHost = env.doc.createElement("div");
|
|
189
|
+
this.#structuredHost.className = "cockpit-structured-host";
|
|
190
|
+
this.#structuredHost.setAttribute("data-structured", "host");
|
|
191
|
+
terminalPanel.appendChild(this.#structuredHost);
|
|
162
192
|
shell.appendChild(this.#matrixRegion);
|
|
163
193
|
shell.appendChild(terminalPanel);
|
|
164
194
|
env.host.appendChild(shell);
|
|
@@ -233,12 +263,18 @@ class Cockpit implements CockpitHandle {
|
|
|
233
263
|
// still cleans it up on the next drill or on dispose().
|
|
234
264
|
this.#terminal?.dispose?.();
|
|
235
265
|
this.#terminal = undefined;
|
|
266
|
+
// The structured view is torn down in lockstep with the terminal.
|
|
267
|
+
this.#structured?.dispose?.();
|
|
268
|
+
this.#structured = undefined;
|
|
236
269
|
|
|
237
270
|
try {
|
|
238
|
-
// Fresh terminal for the newly selected worker.
|
|
271
|
+
// Fresh terminal + structured view for the newly selected worker.
|
|
239
272
|
this.#terminalHost.replaceChildren();
|
|
240
273
|
const sink = this.#env.createTerminal(this.#terminalHost);
|
|
241
274
|
this.#terminal = sink;
|
|
275
|
+
this.#structuredHost.replaceChildren();
|
|
276
|
+
const structured = this.#createStructured(this.#structuredHost);
|
|
277
|
+
this.#structured = structured;
|
|
242
278
|
|
|
243
279
|
let session: TerminalSession | undefined;
|
|
244
280
|
const client = new RelayChannelClient({
|
|
@@ -253,6 +289,7 @@ class Cockpit implements CockpitHandle {
|
|
|
253
289
|
session = new TerminalSession({
|
|
254
290
|
stream,
|
|
255
291
|
sink,
|
|
292
|
+
structured,
|
|
256
293
|
send: (message) => client.sendRelay(message),
|
|
257
294
|
credit: this.#env.credit,
|
|
258
295
|
});
|
|
@@ -271,6 +308,8 @@ class Cockpit implements CockpitHandle {
|
|
|
271
308
|
this.#drill = undefined;
|
|
272
309
|
this.#terminal?.dispose?.();
|
|
273
310
|
this.#terminal = undefined;
|
|
311
|
+
this.#structured?.dispose?.();
|
|
312
|
+
this.#structured = undefined;
|
|
274
313
|
}
|
|
275
314
|
}
|
|
276
315
|
|
package/src/cockpit/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
type RelayInbound,
|
|
35
35
|
type RelayOutbound,
|
|
36
36
|
type RelaySend,
|
|
37
|
+
type StructuredSink,
|
|
37
38
|
type TerminalSessionOptions,
|
|
38
39
|
type TerminalSink,
|
|
39
40
|
} from "./terminal-session.ts";
|
|
@@ -54,10 +55,17 @@ export {
|
|
|
54
55
|
type RenderOptions,
|
|
55
56
|
} from "./render.ts";
|
|
56
57
|
|
|
58
|
+
export {
|
|
59
|
+
createStructuredSink,
|
|
60
|
+
renderStructured,
|
|
61
|
+
type StructuredTerminal,
|
|
62
|
+
} from "./structured-view.ts";
|
|
63
|
+
|
|
57
64
|
export {
|
|
58
65
|
bootCockpit,
|
|
59
66
|
type CockpitEnv,
|
|
60
67
|
type CockpitHandle,
|
|
68
|
+
type CreateStructured,
|
|
61
69
|
type CreateTerminal,
|
|
62
70
|
type TimerHandle,
|
|
63
71
|
} from "./boot.ts";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { type TranscriptEvent, deriveView } from "../transcript/index.ts";
|
|
5
|
+
|
|
6
|
+
import { FakeDocument, FakeElement } from "./fake-dom.ts";
|
|
7
|
+
import { createStructuredSink, renderStructured } from "./structured-view.ts";
|
|
8
|
+
|
|
9
|
+
function fixture(): { host: FakeElement; doc: FakeDocument } {
|
|
10
|
+
return { host: new FakeElement("div"), doc: new FakeDocument() };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("renderStructured renders derived turns, messages and tool cards (not raw JSON)", () => {
|
|
14
|
+
const { host, doc } = fixture();
|
|
15
|
+
const events: TranscriptEvent[] = [
|
|
16
|
+
{ kind: "turn", offset: 0, index: 0 },
|
|
17
|
+
{ kind: "message", offset: 1, role: "user", text: "run the build" },
|
|
18
|
+
{ kind: "message", offset: 2, role: "assistant", text: "on it" },
|
|
19
|
+
{ kind: "tool-call", offset: 3, name: "shell", callId: "c1", args: { cmd: "build" } },
|
|
20
|
+
{ kind: "tool-result", offset: 4, callId: "c1", ok: true, content: "done" },
|
|
21
|
+
{ kind: "lifecycle", offset: 5, phase: "completed" },
|
|
22
|
+
];
|
|
23
|
+
renderStructured(host, doc, deriveView(events));
|
|
24
|
+
|
|
25
|
+
const root = host.byClass("cockpit-structured")[0];
|
|
26
|
+
assert.ok(root, "a structured root is rendered");
|
|
27
|
+
assert.equal(root.getAttribute("data-lifecycle"), "completed");
|
|
28
|
+
|
|
29
|
+
const messages = host.byClass("cockpit-structured-message");
|
|
30
|
+
assert.equal(messages.length, 2);
|
|
31
|
+
assert.equal(messages[0]?.getAttribute("data-role"), "user");
|
|
32
|
+
assert.match(messages[0]?.text() ?? "", /run the build/);
|
|
33
|
+
|
|
34
|
+
const tool = host.byClass("cockpit-structured-tool")[0];
|
|
35
|
+
assert.ok(tool, "the tool card is rendered");
|
|
36
|
+
assert.equal(tool.getAttribute("data-tool"), "shell");
|
|
37
|
+
assert.equal(tool.getAttribute("data-state"), "ok");
|
|
38
|
+
assert.match(tool.text(), /done/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("renderStructured replaces prior content (idempotent re-render)", () => {
|
|
42
|
+
const { host, doc } = fixture();
|
|
43
|
+
renderStructured(host, doc, deriveView([{ kind: "message", offset: 0, role: "assistant", text: "one" }]));
|
|
44
|
+
renderStructured(host, doc, deriveView([{ kind: "message", offset: 0, role: "assistant", text: "two" }]));
|
|
45
|
+
assert.equal(host.byClass("cockpit-structured").length, 1, "no duplicated roots after a re-render");
|
|
46
|
+
assert.equal(host.byClass("cockpit-structured-message").length, 1);
|
|
47
|
+
assert.match(host.byClass("cockpit-structured-message")[0]?.text() ?? "", /two/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("createStructuredSink accumulates events and re-derives the view on each one", () => {
|
|
51
|
+
const { host, doc } = fixture();
|
|
52
|
+
const sink = createStructuredSink(host, doc);
|
|
53
|
+
// An empty view is present up-front, before any event.
|
|
54
|
+
assert.equal(host.byClass("cockpit-structured").length, 1);
|
|
55
|
+
assert.equal(host.byClass("cockpit-structured-message").length, 0);
|
|
56
|
+
|
|
57
|
+
sink.event({ kind: "message", offset: 0, role: "assistant", text: "first" });
|
|
58
|
+
sink.event({ kind: "message", offset: 1, role: "assistant", text: "second" });
|
|
59
|
+
const messages = host.byClass("cockpit-structured-message");
|
|
60
|
+
assert.equal(messages.length, 2, "both accumulated events are folded into the view");
|
|
61
|
+
assert.match(messages[1]?.text() ?? "", /second/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("createStructuredSink.dispose clears the accumulated log and the DOM", () => {
|
|
65
|
+
const { host, doc } = fixture();
|
|
66
|
+
const sink = createStructuredSink(host, doc);
|
|
67
|
+
sink.event({ kind: "message", offset: 0, role: "assistant", text: "hi" });
|
|
68
|
+
sink.dispose();
|
|
69
|
+
assert.equal(host.children.length, 0, "the structured host is emptied on dispose");
|
|
70
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
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, type TranscriptEvent, deriveView } from "../transcript/index.ts";
|
|
23
|
+
import type { DocumentLike, ElementLike } from "./render.ts";
|
|
24
|
+
import type { StructuredSink } from "./terminal-session.ts";
|
|
25
|
+
|
|
26
|
+
function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
|
|
27
|
+
const node = doc.createElement(tag);
|
|
28
|
+
if (className !== undefined) node.className = className;
|
|
29
|
+
if (text !== undefined) node.textContent = text;
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolCard(doc: DocumentLike, tool: DerivedView["tools"][number]): ElementLike {
|
|
34
|
+
const card = el(doc, "div", "cockpit-structured-tool");
|
|
35
|
+
card.setAttribute("data-tool", tool.name);
|
|
36
|
+
card.setAttribute("data-offset", String(tool.offset));
|
|
37
|
+
card.setAttribute("data-state", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
38
|
+
const head = el(doc, "div", "cockpit-structured-tool-head");
|
|
39
|
+
head.appendChild(el(doc, "span", "cockpit-structured-tool-name", tool.name));
|
|
40
|
+
if (tool.callId !== undefined) head.appendChild(el(doc, "span", "cockpit-structured-tool-id", tool.callId));
|
|
41
|
+
card.appendChild(head);
|
|
42
|
+
if (tool.args !== undefined) {
|
|
43
|
+
card.appendChild(el(doc, "pre", "cockpit-structured-tool-args", JSON.stringify(tool.args)));
|
|
44
|
+
}
|
|
45
|
+
if (tool.result !== undefined) {
|
|
46
|
+
const result = el(doc, "div", "cockpit-structured-tool-result");
|
|
47
|
+
result.setAttribute("data-ok", tool.result.ok ? "true" : "false");
|
|
48
|
+
if (tool.result.content !== undefined) result.textContent = tool.result.content;
|
|
49
|
+
card.appendChild(result);
|
|
50
|
+
}
|
|
51
|
+
return card;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function turnSection(doc: DocumentLike, turn: DerivedView["turns"][number]): ElementLike {
|
|
55
|
+
const section = el(doc, "section", "cockpit-structured-turn");
|
|
56
|
+
section.setAttribute("data-turn", String(turn.index));
|
|
57
|
+
section.setAttribute("data-steps", String(turn.steps));
|
|
58
|
+
for (const message of turn.messages) {
|
|
59
|
+
const row = el(doc, "div", "cockpit-structured-message");
|
|
60
|
+
row.setAttribute("data-role", message.role);
|
|
61
|
+
row.setAttribute("data-offset", String(message.offset));
|
|
62
|
+
row.appendChild(el(doc, "span", "cockpit-structured-role", message.role));
|
|
63
|
+
row.appendChild(el(doc, "span", "cockpit-structured-text", message.text));
|
|
64
|
+
section.appendChild(row);
|
|
65
|
+
}
|
|
66
|
+
for (const tool of turn.tools) {
|
|
67
|
+
section.appendChild(toolCard(doc, tool));
|
|
68
|
+
}
|
|
69
|
+
return section;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render a derived structured view into `host`, replacing whatever was there.
|
|
74
|
+
* Idempotent: re-call it with the latest {@link DerivedView} on every new event.
|
|
75
|
+
*/
|
|
76
|
+
export function renderStructured(host: ElementLike, doc: DocumentLike, view: DerivedView): void {
|
|
77
|
+
host.replaceChildren();
|
|
78
|
+
const root = el(doc, "div", "cockpit-structured");
|
|
79
|
+
root.setAttribute("data-lifecycle", view.lifecycle);
|
|
80
|
+
root.setAttribute("data-events", String(view.eventCount));
|
|
81
|
+
for (const turn of view.turns) {
|
|
82
|
+
root.appendChild(turnSection(doc, turn));
|
|
83
|
+
}
|
|
84
|
+
host.appendChild(root);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A structured sink with a `dispose` teardown (mirrors {@link TerminalSink}). */
|
|
88
|
+
export interface StructuredTerminal extends StructuredSink {
|
|
89
|
+
dispose(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a {@link StructuredSink} that accumulates the offset-ordered transcript
|
|
94
|
+
* events a structured stream delivers, folds them through the canonical
|
|
95
|
+
* {@link deriveView}, and renders the derived view into `host` on each event. The
|
|
96
|
+
* accumulated log is this sink's own state, so constructing one per drill-in gives
|
|
97
|
+
* each worker its own structured view.
|
|
98
|
+
*/
|
|
99
|
+
export function createStructuredSink(host: ElementLike, doc: DocumentLike): StructuredTerminal {
|
|
100
|
+
const events: TranscriptEvent[] = [];
|
|
101
|
+
// Render an empty derived view up-front so the structured region is present and
|
|
102
|
+
// consistent before the first event lands.
|
|
103
|
+
renderStructured(host, doc, deriveView(events));
|
|
104
|
+
return {
|
|
105
|
+
event(event: TranscriptEvent): void {
|
|
106
|
+
events.push(event);
|
|
107
|
+
renderStructured(host, doc, deriveView(events));
|
|
108
|
+
},
|
|
109
|
+
dispose(): void {
|
|
110
|
+
events.length = 0;
|
|
111
|
+
host.replaceChildren();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -2,23 +2,30 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
|
|
4
4
|
import type { RelayPayload } from "../protocol/index.ts";
|
|
5
|
+
import { TRANSCRIPT_EVENT_MARKER, type TranscriptEvent, encodeTranscriptEvent } from "../transcript/index.ts";
|
|
5
6
|
|
|
6
|
-
import { type RelayOutbound, TerminalSession } from "./terminal-session.ts";
|
|
7
|
+
import { type RelayOutbound, type StructuredSink, TerminalSession } from "./terminal-session.ts";
|
|
7
8
|
|
|
8
9
|
interface Harness {
|
|
9
10
|
readonly session: TerminalSession;
|
|
10
11
|
readonly sent: RelayOutbound[];
|
|
11
12
|
readonly writes: string[];
|
|
13
|
+
readonly events: TranscriptEvent[];
|
|
12
14
|
data(offset: number, chunk: string, stream?: string): RelayPayload;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
function harness(options: { from?: number; credit?: number; stream?: string } = {}): Harness {
|
|
17
|
+
function harness(options: { from?: number; credit?: number; stream?: string; structured?: boolean } = {}): Harness {
|
|
16
18
|
const stream = options.stream ?? "worker-1";
|
|
17
19
|
const sent: RelayOutbound[] = [];
|
|
18
20
|
const writes: string[] = [];
|
|
21
|
+
const events: TranscriptEvent[] = [];
|
|
22
|
+
const structured: StructuredSink | undefined = options.structured
|
|
23
|
+
? { event: (event) => events.push(event) }
|
|
24
|
+
: undefined;
|
|
19
25
|
const session = new TerminalSession({
|
|
20
26
|
stream,
|
|
21
27
|
sink: { write: (chunk) => writes.push(chunk) },
|
|
28
|
+
structured,
|
|
22
29
|
send: (message) => sent.push(message),
|
|
23
30
|
from: options.from,
|
|
24
31
|
credit: options.credit,
|
|
@@ -27,6 +34,7 @@ function harness(options: { from?: number; credit?: number; stream?: string } =
|
|
|
27
34
|
session,
|
|
28
35
|
sent,
|
|
29
36
|
writes,
|
|
37
|
+
events,
|
|
30
38
|
data: (offset, chunk, s = stream) => ({ stream: s, offset, chunk }),
|
|
31
39
|
};
|
|
32
40
|
}
|
|
@@ -250,3 +258,88 @@ test("grant rejects a non-positive or unsafe credit", () => {
|
|
|
250
258
|
}
|
|
251
259
|
});
|
|
252
260
|
|
|
261
|
+
// A marker-tagged chunk (encoded via the ONE canonical grammar, never a hand-rolled marker literal).
|
|
262
|
+
function structuredChunk(event: TranscriptEvent): string {
|
|
263
|
+
return encodeTranscriptEvent(event);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test("a structured (marker-tagged) chunk routes to the structured sink, NOT the byte-terminal", () => {
|
|
267
|
+
const h = harness({ structured: true });
|
|
268
|
+
h.session.attach();
|
|
269
|
+
h.session.handle(h.data(0, structuredChunk({ kind: "message", offset: 0, role: "assistant", text: "hi" })));
|
|
270
|
+
assert.deepEqual(h.writes, [], "a structured chunk must not be dumped into the byte-terminal");
|
|
271
|
+
assert.equal(h.events.length, 1);
|
|
272
|
+
assert.deepEqual(h.events[0], { kind: "message", offset: 0, role: "assistant", text: "hi" });
|
|
273
|
+
assert.equal(h.session.nextOffset, 1, "the resume offset advances for a structured chunk too");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("a raw (untagged) chunk still renders on the byte-terminal even when a structured sink is wired", () => {
|
|
277
|
+
const h = harness({ structured: true });
|
|
278
|
+
h.session.attach();
|
|
279
|
+
h.session.handle(h.data(0, "plain bytes\n"));
|
|
280
|
+
assert.deepEqual(h.writes, ["plain bytes\n"]);
|
|
281
|
+
assert.deepEqual(h.events, [], "raw bytes never reach the structured sink");
|
|
282
|
+
assert.equal(h.session.nextOffset, 1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("with no structured sink a marker-tagged chunk is written verbatim (legacy raw-only behaviour)", () => {
|
|
286
|
+
const h = harness();
|
|
287
|
+
h.session.attach();
|
|
288
|
+
const chunk = structuredChunk({ kind: "message", offset: 0, role: "assistant", text: "hi" });
|
|
289
|
+
h.session.handle(h.data(0, chunk));
|
|
290
|
+
assert.deepEqual(h.writes, [chunk], "without a structured sink the envelope falls through to the byte-terminal");
|
|
291
|
+
assert.equal(h.session.nextOffset, 1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a mixed stream that starts raw and only later carries tagged chunks routes each chunk correctly", () => {
|
|
295
|
+
const h = harness({ structured: true });
|
|
296
|
+
h.session.attach();
|
|
297
|
+
h.session.handle(h.data(0, "booting...\n"));
|
|
298
|
+
h.session.handle(h.data(1, structuredChunk({ kind: "turn", offset: 1, index: 0 })));
|
|
299
|
+
h.session.handle(h.data(2, structuredChunk({ kind: "message", offset: 2, role: "assistant", text: "done" })));
|
|
300
|
+
h.session.handle(h.data(3, "trailing raw\n"));
|
|
301
|
+
assert.deepEqual(h.writes, ["booting...\n", "trailing raw\n"], "raw chunks land on the terminal");
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
h.events.map((e) => e.kind),
|
|
304
|
+
["turn", "message"],
|
|
305
|
+
"only the tagged chunks reach the structured sink",
|
|
306
|
+
);
|
|
307
|
+
assert.equal(h.session.nextOffset, 4);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("a marker-tagged but malformed envelope falls back to raw bytes (byte-terminal), never the structured sink", () => {
|
|
311
|
+
// A chunk mentioning the marker but with an unknown/rejected body must be
|
|
312
|
+
// retained verbatim for byte-replay fidelity, not routed as a structured event.
|
|
313
|
+
const h = harness({ structured: true });
|
|
314
|
+
h.session.attach();
|
|
315
|
+
const malformed = JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: 1, kind: "message" }); // no text → decoder rejects
|
|
316
|
+
h.session.handle(h.data(0, malformed));
|
|
317
|
+
assert.deepEqual(h.writes, [malformed]);
|
|
318
|
+
assert.deepEqual(h.events, []);
|
|
319
|
+
assert.equal(h.session.nextOffset, 1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("a structured reconnect resumes from nextOffset — no dropped and no double-applied events", () => {
|
|
323
|
+
const h = harness({ structured: true });
|
|
324
|
+
h.session.attach();
|
|
325
|
+
h.session.handle(h.data(0, structuredChunk({ kind: "turn", offset: 0, index: 0 })));
|
|
326
|
+
h.session.handle(h.data(1, structuredChunk({ kind: "message", offset: 1, role: "assistant", text: "a" })));
|
|
327
|
+
assert.equal(h.session.nextOffset, 2);
|
|
328
|
+
|
|
329
|
+
// Socket drops; the client reconnects → re-attach resumes from offset 2.
|
|
330
|
+
h.session.attach();
|
|
331
|
+
assert.deepEqual(h.sent[1], { op: "subscribe", stream: "worker-1", from: 2, credit: 1024 });
|
|
332
|
+
|
|
333
|
+
// The hub replays the retained tail (re-sends offset 1) then continues. The
|
|
334
|
+
// replayed event is below nextOffset and must be dropped (no double-apply);
|
|
335
|
+
// the fresh event applies exactly once (no loss).
|
|
336
|
+
h.session.handle(h.data(1, structuredChunk({ kind: "message", offset: 1, role: "assistant", text: "a" })));
|
|
337
|
+
h.session.handle(h.data(2, structuredChunk({ kind: "message", offset: 2, role: "assistant", text: "b" })));
|
|
338
|
+
assert.deepEqual(
|
|
339
|
+
h.events.map((e) => (e.kind === "message" ? e.text : e.kind)),
|
|
340
|
+
["turn", "a", "b"],
|
|
341
|
+
"no dropped and no duplicated structured events across the reconnect",
|
|
342
|
+
);
|
|
343
|
+
assert.equal(h.session.nextOffset, 3);
|
|
344
|
+
});
|
|
345
|
+
|
|
@@ -19,9 +19,25 @@
|
|
|
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";
|
|
24
39
|
import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.ts";
|
|
40
|
+
import { type TranscriptEvent, parseTranscriptEvent } from "../transcript/index.ts";
|
|
25
41
|
|
|
26
42
|
/** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
|
|
27
43
|
export interface TerminalSink {
|
|
@@ -31,6 +47,20 @@ export interface TerminalSink {
|
|
|
31
47
|
dispose?(): void;
|
|
32
48
|
}
|
|
33
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The structured sink a session routes decoded transcript events to when the stream
|
|
52
|
+
* is a structured ACP stream (marker-tagged chunks). It receives the offset-keyed,
|
|
53
|
+
* immutable {@link TranscriptEvent} the one canonical {@link parseTranscriptEvent}
|
|
54
|
+
* derived from the chunk — never raw JSON — so the derived structured renderer folds
|
|
55
|
+
* over typed events rather than pretty-printing bytes.
|
|
56
|
+
*/
|
|
57
|
+
export interface StructuredSink {
|
|
58
|
+
/** Apply one decoded structured transcript event (in offset order). */
|
|
59
|
+
event(event: TranscriptEvent): void;
|
|
60
|
+
/** Tear down the underlying structured widget and its listeners, if any. */
|
|
61
|
+
dispose?(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
/** An outbound relay message the session asks its transport to send. */
|
|
35
65
|
export type RelayOutbound =
|
|
36
66
|
| { readonly op: "subscribe"; readonly stream: string; readonly from: number; readonly credit: number }
|
|
@@ -47,8 +77,15 @@ export type RelaySend = (message: RelayOutbound) => void;
|
|
|
47
77
|
export interface TerminalSessionOptions {
|
|
48
78
|
/** The relay stream id (one worker's terminal). */
|
|
49
79
|
readonly stream: string;
|
|
50
|
-
/** Where decoded output is written. */
|
|
80
|
+
/** Where decoded raw output is written. */
|
|
51
81
|
readonly sink: TerminalSink;
|
|
82
|
+
/**
|
|
83
|
+
* Where decoded structured transcript events are routed when the stream is a
|
|
84
|
+
* structured ACP stream (marker-tagged chunks). Omit for a pure byte-terminal:
|
|
85
|
+
* without it every chunk — even a marker-tagged one — is written verbatim to
|
|
86
|
+
* {@link sink}, preserving the legacy raw-only behaviour.
|
|
87
|
+
*/
|
|
88
|
+
readonly structured?: StructuredSink;
|
|
52
89
|
/** Emits outbound relay messages. */
|
|
53
90
|
readonly send: RelaySend;
|
|
54
91
|
/** Bulk credit requested on each (re)subscribe. Default 1024. */
|
|
@@ -73,6 +110,7 @@ function isRelayData(message: RelayInbound): message is RelayPayload {
|
|
|
73
110
|
export class TerminalSession {
|
|
74
111
|
readonly #stream: string;
|
|
75
112
|
readonly #sink: TerminalSink;
|
|
113
|
+
readonly #structured: StructuredSink | undefined;
|
|
76
114
|
readonly #send: RelaySend;
|
|
77
115
|
readonly #credit: number;
|
|
78
116
|
readonly #onGap: TerminalSessionOptions["onGap"];
|
|
@@ -84,6 +122,7 @@ export class TerminalSession {
|
|
|
84
122
|
constructor(options: TerminalSessionOptions) {
|
|
85
123
|
this.#stream = options.stream;
|
|
86
124
|
this.#sink = options.sink;
|
|
125
|
+
this.#structured = options.structured;
|
|
87
126
|
this.#send = options.send;
|
|
88
127
|
this.#credit = options.credit ?? DEFAULT_CREDIT;
|
|
89
128
|
if (!isPosInt(this.#credit)) {
|
|
@@ -152,19 +191,41 @@ export class TerminalSession {
|
|
|
152
191
|
// Idempotent apply: a reconnect resubscribes from nextOffset, so the hub may
|
|
153
192
|
// re-deliver the boundary chunk; anything we have already applied is dropped.
|
|
154
193
|
if (data.offset < this.#nextOffset) return;
|
|
155
|
-
// Compute the next resume point BEFORE
|
|
194
|
+
// Compute the next resume point BEFORE applying so the apply is atomic: a
|
|
156
195
|
// chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
|
|
157
|
-
// before we touch
|
|
196
|
+
// before we touch either sink — otherwise the chunk is applied but #nextOffset
|
|
158
197
|
// is not advanced, leaving a partially-applied state that re-delivers (and
|
|
159
198
|
// so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
|
|
160
199
|
// fast rather than overflowing into an unsafe nextOffset — that value would
|
|
161
200
|
// later be echoed in subscribe.from and lose precision on any JSON
|
|
162
201
|
// round-trip, silently corrupting resume semantics.
|
|
163
202
|
const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
|
|
164
|
-
this.#
|
|
203
|
+
this.#apply(data);
|
|
165
204
|
this.#nextOffset = nextOffset;
|
|
166
205
|
}
|
|
167
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Route one already-de-duplicated data chunk to the right surface. Classify it
|
|
209
|
+
* through the ONE canonical {@link parseTranscriptEvent}: a marker-tagged, known
|
|
210
|
+
* envelope decodes to a typed event and (when a {@link StructuredSink} is wired)
|
|
211
|
+
* is routed there — the derived structured renderer — instead of dumping raw JSON
|
|
212
|
+
* into the byte-terminal; anything else (raw bytes, non-JSON, an untagged or
|
|
213
|
+
* malformed envelope) falls back to `stream-chunk` and is written verbatim to the
|
|
214
|
+
* byte {@link TerminalSink}, so a legacy/raw or mixed stream renders exactly as
|
|
215
|
+
* before. With no structured sink the parse is skipped entirely, keeping the
|
|
216
|
+
* raw-only path byte-identical to a pure byte-terminal.
|
|
217
|
+
*/
|
|
218
|
+
#apply(data: RelayPayload): void {
|
|
219
|
+
if (this.#structured !== undefined) {
|
|
220
|
+
const event = parseTranscriptEvent({ offset: data.offset, chunk: data.chunk });
|
|
221
|
+
if (event.kind !== "stream-chunk") {
|
|
222
|
+
this.#structured.event(event);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.#sink.write(data.chunk);
|
|
227
|
+
}
|
|
228
|
+
|
|
168
229
|
#onSubscribed(gap: boolean, nextOffset: number): void {
|
|
169
230
|
// Validate the ack's resume point exactly as the constructor validates
|
|
170
231
|
// `from`: `nextOffset` is echoed back into subscribe.from on the next
|