@cotal-ai/connector-core 0.18.0 → 0.19.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/agent.d.ts.map +1 -1
- package/dist/agent.js +12 -2
- package/dist/agent.js.map +1 -1
- package/dist/agui-holder.d.ts +89 -0
- package/dist/agui-holder.d.ts.map +1 -0
- package/dist/agui-holder.js +135 -0
- package/dist/agui-holder.js.map +1 -0
- package/dist/agui-render.d.ts +100 -0
- package/dist/agui-render.d.ts.map +1 -0
- package/dist/agui-render.js +283 -0
- package/dist/agui-render.js.map +1 -0
- package/dist/agui-wal-path.d.ts +103 -0
- package/dist/agui-wal-path.d.ts.map +1 -0
- package/dist/agui-wal-path.js +324 -0
- package/dist/agui-wal-path.js.map +1 -0
- package/dist/agui.d.ts +727 -0
- package/dist/agui.d.ts.map +1 -0
- package/dist/agui.js +1114 -0
- package/dist/agui.js.map +1 -0
- package/dist/docs-bundle.generated.d.ts.map +1 -1
- package/dist/docs-bundle.generated.js +16 -9
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/durable-source.d.ts +127 -0
- package/dist/durable-source.d.ts.map +1 -0
- package/dist/durable-source.js +219 -0
- package/dist/durable-source.js.map +1 -0
- package/dist/event-wal.d.ts +265 -0
- package/dist/event-wal.d.ts.map +1 -0
- package/dist/event-wal.js +698 -0
- package/dist/event-wal.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +78 -2
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +120 -0
- package/dist/launch.js.map +1 -1
- package/dist/tool-specs.d.ts +40 -2
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +51 -1
- package/dist/tool-specs.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -6
- package/dist/tools.js.map +1 -1
- package/package.json +8 -2
package/dist/agui.js
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AG-UI event VOCABULARY and the Cotal frame envelope.
|
|
3
|
+
*
|
|
4
|
+
* This is the file that makes the change an abolition rather than a rename. Renaming `tr-<name>` to
|
|
5
|
+
* `events.<name>` while the connectors still publish `condense()` output would move glyph-prefixed
|
|
6
|
+
* text to a new channel and change nothing a consumer can do with it — the channel name was never
|
|
7
|
+
* the complaint.
|
|
8
|
+
*
|
|
9
|
+
* **The vocabulary is adopted; the SDK is not.** `@ag-ui/core` is a `devDependency`,
|
|
10
|
+
* pinned EXACT at `0.0.57`, and this file imports from it with `import type` ONLY. The reason is
|
|
11
|
+
* measured rather than stylistic: `0.0.57` declares `dependencies: { zod: "^3.22.4" }` (verified
|
|
12
|
+
* against the registry, not remembered), and `connector-core` is esbuild-bundled into every seeded
|
|
13
|
+
* connector — so a runtime dependency would ship a second zod major to every customer in order to
|
|
14
|
+
* validate events we construct ourselves. The conformance smoke imports the real schemas and
|
|
15
|
+
* validates against them; production code carries types and string literals and no zod.
|
|
16
|
+
*
|
|
17
|
+
* **Promotion trigger, conditional and NOT scheduled:** if a 0.1.x ships stable with zod moved to
|
|
18
|
+
* `peerDependencies`, promote to a runtime dependency and use the schemas directly. As of this
|
|
19
|
+
* writing `latest` is `0.0.57`, the active `canary` still carries the zod-3 runtime dep, and the
|
|
20
|
+
* release that moves zod to a peer sits on no dist-tag at all.
|
|
21
|
+
*
|
|
22
|
+
* ## What is here, and what is deliberately NOT
|
|
23
|
+
*
|
|
24
|
+
* Here: the event constructors, the frame envelope, the routing/validity split
|
|
25
|
+
* ({@link isAguiFramePart} / {@link parseAguiFrame}), the {@link AguiBrackets} stream machine, and
|
|
26
|
+
* the `cotal.*` `CUSTOM` table, which is empty in v1.
|
|
27
|
+
*
|
|
28
|
+
* Not here, and NAMED rather than stubbed, because a stub is a claim that the shape is known: the
|
|
29
|
+
* channel derivation, the `max_payload` split, and the emitter that publishes. Each of those talks
|
|
30
|
+
* to something outside this module, and each lands with the surface that calls it.
|
|
31
|
+
*
|
|
32
|
+
* **Nothing constructs a frame outside a smoke.** No connector emits, and this module publishes
|
|
33
|
+
* nothing: every export below is a pure function of its arguments. That is a statement about the
|
|
34
|
+
* tree, not a disclaimer — a reader deciding whether a change here can reach a customer needs it to
|
|
35
|
+
* be accurate, so it is maintained rather than left to rot.
|
|
36
|
+
*/
|
|
37
|
+
// The frame's wire identity lives in `@cotal-ai/core`: an adopted vocabulary is a standard concept,
|
|
38
|
+
// and core must be able to RENDER a frame without depending on an extension. Re-exported here so an
|
|
39
|
+
// importer of this module gets the whole producer-side vocabulary as one surface. The constructors,
|
|
40
|
+
// the envelope and all validation stay in this file — only the identity is in core. See
|
|
41
|
+
// `packages/core/src/agui-kind.ts`.
|
|
42
|
+
export { AGUI_FRAME_KIND, AGUI_EVENT_TYPE, isAguiFramePart } from "@cotal-ai/core";
|
|
43
|
+
import { AGUI_FRAME_KIND, AGUI_EVENT_TYPE, isAguiFramePart } from "@cotal-ai/core";
|
|
44
|
+
import { randomUUID } from "node:crypto";
|
|
45
|
+
import { isCasLoss, principalKey } from "@cotal-ai/core";
|
|
46
|
+
import { eventChannelForSession } from "./launch.js";
|
|
47
|
+
/**
|
|
48
|
+
* The `cotal.*` `CUSTOM` event table — the second and ONLY other vehicle for Cotal-specific data.
|
|
49
|
+
*
|
|
50
|
+
* **The v1 table is EMPTY, and that is the specification, not an unfinished state.** Earlier
|
|
51
|
+
* revisions described a two-member table holding `cotal.ask.opened` / `cotal.ask.settled`. Both
|
|
52
|
+
* were removed when ask state left this plane, on the operative ground that there is no consumer:
|
|
53
|
+
* the board answers what is owed, the plane carries what is happening. Leaving the count in the
|
|
54
|
+
* prose invited re-adding them "because the table has two slots", so the table ships with no slots.
|
|
55
|
+
*
|
|
56
|
+
* It exists as the GATE: adding a member is a decision that touches this declaration, rather than
|
|
57
|
+
* a `CUSTOM` name invented at a call site where nobody reviews the vocabulary.
|
|
58
|
+
*/
|
|
59
|
+
export const COTAL_CUSTOM_EVENTS = [];
|
|
60
|
+
/** The envelope version. One frame declares the AG-UI vocabulary version it was built against. */
|
|
61
|
+
export const AGUI_PROTOCOL = "ag-ui/0.0.57";
|
|
62
|
+
/** Raised when a frame or an event sequence violates a structural rule of the vocabulary. */
|
|
63
|
+
export class AguiVocabularyError extends Error {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "AguiVocabularyError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Bracketing, checked INCREMENTALLY over the event sequence — deliberately not over one frame.
|
|
71
|
+
*
|
|
72
|
+
* **A frame is not guaranteed to be self-bracketed, and a validator demanding that it be would
|
|
73
|
+
* forbid a split this design requires.** An oversized frame splits on event boundaries with each
|
|
74
|
+
* part carrying its own `seq`, so a run can legally open in one frame and close in the next. The
|
|
75
|
+
* unit that must balance is the WRITER'S STREAM, not the message. Feeding this machine frame after
|
|
76
|
+
* frame is therefore the only way to check the property that is actually claimed.
|
|
77
|
+
*
|
|
78
|
+
* What it enforces:
|
|
79
|
+
* - one run open at a time; nothing may be emitted outside an open run
|
|
80
|
+
* - `TEXT_MESSAGE_*`, `REASONING_MESSAGE_*` and `TOOL_CALL_*` open and close by their own id, and an
|
|
81
|
+
* id may not be opened twice while already open
|
|
82
|
+
* - a run may not close while any message or tool call it opened is still open
|
|
83
|
+
*
|
|
84
|
+
* The id-reuse rule is the one with a measured defect behind it: `message.id` is a PROVIDER REQUEST
|
|
85
|
+
* id, and over one real session 833 of 1243 assistant `message.id` values appeared in more than one
|
|
86
|
+
* JSONL entry. Keying identity on it would open and close the same `messageId` repeatedly, which
|
|
87
|
+
* the AG-UI verifier rejects and the reference reducer collapses. This machine refuses that rather
|
|
88
|
+
* than letting it reach a consumer.
|
|
89
|
+
*/
|
|
90
|
+
export class AguiBrackets {
|
|
91
|
+
run;
|
|
92
|
+
text = new Set();
|
|
93
|
+
reasoning = new Set();
|
|
94
|
+
tools = new Set();
|
|
95
|
+
/**
|
|
96
|
+
* The machine's whole state, as plain JSON — what the WAL persists so a restart does not lose it.
|
|
97
|
+
*
|
|
98
|
+
* Sorted, because this value is written to disk and compared BY A HUMAN reading two documents.
|
|
99
|
+
* A `Set`'s iteration order is insertion order, so two machines that are semantically identical
|
|
100
|
+
* would serialize differently depending on the order events happened to arrive, and a diff of two
|
|
101
|
+
* WALs would show a change where there is none.
|
|
102
|
+
*/
|
|
103
|
+
snapshot() {
|
|
104
|
+
return {
|
|
105
|
+
run: this.run,
|
|
106
|
+
text: [...this.text].sort(),
|
|
107
|
+
reasoning: [...this.reasoning].sort(),
|
|
108
|
+
tools: [...this.tools].sort(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Rebuild a machine from a snapshot. The inverse of {@link snapshot}, and the reason a mid-run
|
|
112
|
+
* restart can continue instead of refusing its first event. */
|
|
113
|
+
static restore(s) {
|
|
114
|
+
const b = new AguiBrackets();
|
|
115
|
+
b.run = s.run;
|
|
116
|
+
for (const id of s.text)
|
|
117
|
+
b.text.add(id);
|
|
118
|
+
for (const id of s.reasoning)
|
|
119
|
+
b.reasoning.add(id);
|
|
120
|
+
for (const id of s.tools)
|
|
121
|
+
b.tools.add(id);
|
|
122
|
+
return b;
|
|
123
|
+
}
|
|
124
|
+
/** An independent machine at the same state — used to VALIDATE a batch without advancing the
|
|
125
|
+
* machine that is in step with the disk. */
|
|
126
|
+
clone() {
|
|
127
|
+
return AguiBrackets.restore(this.snapshot());
|
|
128
|
+
}
|
|
129
|
+
/** True while a run is open — i.e. the stream is mid-turn and not at a legal stopping point. */
|
|
130
|
+
get open() {
|
|
131
|
+
return this.run !== undefined;
|
|
132
|
+
}
|
|
133
|
+
/** The run currently open, for diagnostics and for checking a frame's envelope against it. */
|
|
134
|
+
get runId() {
|
|
135
|
+
return this.run;
|
|
136
|
+
}
|
|
137
|
+
/** Feed one event. Throws {@link AguiVocabularyError} on the first violation. */
|
|
138
|
+
accept(event) {
|
|
139
|
+
const e = event;
|
|
140
|
+
const t = e.type;
|
|
141
|
+
if (t === AGUI_EVENT_TYPE.RUN_STARTED) {
|
|
142
|
+
if (this.run !== undefined)
|
|
143
|
+
throw new AguiVocabularyError(`RUN_STARTED for "${String(e.runId)}" while run "${this.run}" is still open`);
|
|
144
|
+
this.run = String(e.runId);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (this.run === undefined)
|
|
148
|
+
throw new AguiVocabularyError(`${t} emitted outside an open run`);
|
|
149
|
+
if (t === AGUI_EVENT_TYPE.RUN_FINISHED || t === AGUI_EVENT_TYPE.RUN_ERROR) {
|
|
150
|
+
// RUN_ERROR carries no runId of its own (its schema has `message` and `code`), so only
|
|
151
|
+
// RUN_FINISHED can be checked against the open run. Checking what exists rather than
|
|
152
|
+
// pretending to check both.
|
|
153
|
+
if (t === AGUI_EVENT_TYPE.RUN_FINISHED && String(e.runId) !== this.run)
|
|
154
|
+
throw new AguiVocabularyError(`RUN_FINISHED for "${String(e.runId)}" but the open run is "${this.run}"`);
|
|
155
|
+
const dangling = [...this.text, ...this.reasoning, ...this.tools];
|
|
156
|
+
if (dangling.length > 0)
|
|
157
|
+
throw new AguiVocabularyError(`${t} while still open: ${dangling.join(", ")}`);
|
|
158
|
+
this.run = undefined;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
switch (t) {
|
|
162
|
+
case AGUI_EVENT_TYPE.TEXT_MESSAGE_START:
|
|
163
|
+
return this.openId(this.text, String(e.messageId), t);
|
|
164
|
+
case AGUI_EVENT_TYPE.TEXT_MESSAGE_CONTENT:
|
|
165
|
+
return this.requireOpen(this.text, String(e.messageId), t);
|
|
166
|
+
case AGUI_EVENT_TYPE.TEXT_MESSAGE_END:
|
|
167
|
+
return this.closeId(this.text, String(e.messageId), t);
|
|
168
|
+
case AGUI_EVENT_TYPE.REASONING_MESSAGE_START:
|
|
169
|
+
return this.openId(this.reasoning, String(e.messageId), t);
|
|
170
|
+
case AGUI_EVENT_TYPE.REASONING_MESSAGE_CONTENT:
|
|
171
|
+
return this.requireOpen(this.reasoning, String(e.messageId), t);
|
|
172
|
+
case AGUI_EVENT_TYPE.REASONING_MESSAGE_END:
|
|
173
|
+
return this.closeId(this.reasoning, String(e.messageId), t);
|
|
174
|
+
case AGUI_EVENT_TYPE.TOOL_CALL_START:
|
|
175
|
+
return this.openId(this.tools, String(e.toolCallId), t);
|
|
176
|
+
case AGUI_EVENT_TYPE.TOOL_CALL_ARGS:
|
|
177
|
+
return this.requireOpen(this.tools, String(e.toolCallId), t);
|
|
178
|
+
case AGUI_EVENT_TYPE.TOOL_CALL_END:
|
|
179
|
+
return this.closeId(this.tools, String(e.toolCallId), t);
|
|
180
|
+
case AGUI_EVENT_TYPE.TOOL_CALL_RESULT:
|
|
181
|
+
// A result arrives AFTER its call closed — the harness reports it as a separate
|
|
182
|
+
// observation — so this asserts the call is NOT open rather than that it is.
|
|
183
|
+
if (this.tools.has(String(e.toolCallId)))
|
|
184
|
+
throw new AguiVocabularyError(`TOOL_CALL_RESULT for "${String(e.toolCallId)}" while its call is still open`);
|
|
185
|
+
return;
|
|
186
|
+
case AGUI_EVENT_TYPE.CUSTOM:
|
|
187
|
+
if (!COTAL_CUSTOM_EVENTS.includes(String(e.name)))
|
|
188
|
+
throw new AguiVocabularyError(`CUSTOM "${String(e.name)}" is not declared in COTAL_CUSTOM_EVENTS (the v1 table is empty by specification)`);
|
|
189
|
+
return;
|
|
190
|
+
default:
|
|
191
|
+
throw new AguiVocabularyError(`${t} is not in the mapped subset this plane emits`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Assert the stream is at a legal stopping point.
|
|
196
|
+
*
|
|
197
|
+
* Called at the end of a synthesized sequence and by any consumer checking a writer closed
|
|
198
|
+
* cleanly. NOT called per frame: mid-turn frames are legally unbalanced.
|
|
199
|
+
*/
|
|
200
|
+
assertClosed() {
|
|
201
|
+
if (this.run !== undefined)
|
|
202
|
+
throw new AguiVocabularyError(`run "${this.run}" was never closed`);
|
|
203
|
+
}
|
|
204
|
+
openId(set, id, t) {
|
|
205
|
+
if (set.has(id))
|
|
206
|
+
throw new AguiVocabularyError(`${t} re-opened "${id}" while already open`);
|
|
207
|
+
set.add(id);
|
|
208
|
+
}
|
|
209
|
+
requireOpen(set, id, t) {
|
|
210
|
+
if (!set.has(id))
|
|
211
|
+
throw new AguiVocabularyError(`${t} for "${id}" which is not open`);
|
|
212
|
+
}
|
|
213
|
+
closeId(set, id, t) {
|
|
214
|
+
if (!set.delete(id))
|
|
215
|
+
throw new AguiVocabularyError(`${t} for "${id}" which is not open`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Build a frame, validating the envelope's own fields.
|
|
220
|
+
*
|
|
221
|
+
* Bracketing is NOT checked here — see {@link AguiBrackets} for why a single frame cannot be
|
|
222
|
+
* required to balance. The emitter holds one `AguiBrackets` across the whole stream and feeds it as
|
|
223
|
+
* it builds; that is the placement that checks the property actually claimed.
|
|
224
|
+
*/
|
|
225
|
+
export function aguiFrame(opts) {
|
|
226
|
+
for (const [field, value] of [
|
|
227
|
+
["threadId", opts.threadId],
|
|
228
|
+
["runId", opts.runId],
|
|
229
|
+
["epoch", opts.epoch],
|
|
230
|
+
])
|
|
231
|
+
if (typeof value !== "string" || value.length === 0)
|
|
232
|
+
throw new AguiVocabularyError(`frame ${field} must be a non-empty string`);
|
|
233
|
+
if (!Number.isSafeInteger(opts.seq) || opts.seq < 0)
|
|
234
|
+
throw new AguiVocabularyError(`frame seq must be a non-negative safe integer, got ${JSON.stringify(opts.seq)}`);
|
|
235
|
+
// An empty frame is refused rather than published as a no-op: a consumer counting `seq` would
|
|
236
|
+
// see a gap-free stream carrying nothing, which is the silent-loss shape this plane exists to
|
|
237
|
+
// make impossible.
|
|
238
|
+
if (!Array.isArray(opts.events) || opts.events.length === 0)
|
|
239
|
+
throw new AguiVocabularyError("a frame must carry at least one event");
|
|
240
|
+
return {
|
|
241
|
+
kind: AGUI_FRAME_KIND,
|
|
242
|
+
protocol: AGUI_PROTOCOL,
|
|
243
|
+
threadId: opts.threadId,
|
|
244
|
+
runId: opts.runId,
|
|
245
|
+
epoch: opts.epoch,
|
|
246
|
+
seq: opts.seq,
|
|
247
|
+
events: opts.events,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* **THE CONSUMER-SIDE ENFORCEMENT POINT.** `aguiFrame` above validates on the way OUT; this pair
|
|
252
|
+
* validates on the way IN, and they are separate functions because they are separate trust domains.
|
|
253
|
+
*
|
|
254
|
+
* It exists because the renderers cannot enforce a contract expressed as TypeScript types.
|
|
255
|
+
* `implementations/web/tsconfig.json` carries `"include": ["src"]` with `"exclude": ["src/web"]`,
|
|
256
|
+
* and the build copies `src/web` into `dist` verbatim — so the renderer is plain JavaScript that
|
|
257
|
+
* `tsc` never reads. A prose contract with no enforcement point is the defect, so the contract ships
|
|
258
|
+
* as **a function a consumer executes**, not as a shape a consumer is trusted to have read.
|
|
259
|
+
*
|
|
260
|
+
* **THE TWO ANSWERS ARE DIFFERENT AND MUST NOT BE FUSED.** "This part is not mine" is a routing
|
|
261
|
+
* decision a consumer makes constantly and quietly — every non-frame part on a channel it also
|
|
262
|
+
* reads. "This part claims to be mine and is malformed" is a defect that must be LOUD. One function
|
|
263
|
+
* returning `null` for both would make a version skew look exactly like someone else's message, and
|
|
264
|
+
* a renderer would show an empty pane for a stream it is actively failing to parse. So:
|
|
265
|
+
* {@link isAguiFramePart} answers the routing question with a boolean and never throws, and
|
|
266
|
+
* {@link parseAguiFrame} answers the validity question and throws with the field named.
|
|
267
|
+
*/
|
|
268
|
+
/**
|
|
269
|
+
* Validate an incoming frame, or throw {@link AguiVocabularyError} naming the field that failed.
|
|
270
|
+
*
|
|
271
|
+
* Call it only on a part {@link isAguiFramePart} accepted. Everything after that check is a defect
|
|
272
|
+
* rather than a routing outcome, including an unknown `protocol` — **a version skew must fail loud
|
|
273
|
+
* rather than render partially**, because a consumer that drops the fields it does not recognise
|
|
274
|
+
* shows a confidently incomplete transcript, which is worse than showing nothing.
|
|
275
|
+
*
|
|
276
|
+
* It deliberately does NOT check bracketing. A frame is not guaranteed to be self-bracketed — an
|
|
277
|
+
* oversized frame splits on event boundaries — so the unit that must balance is the writer's stream,
|
|
278
|
+
* and {@link AguiBrackets} is the machine for that, fed frame after frame. A validator demanding a
|
|
279
|
+
* frame balance on its own would forbid a split the plan mandates.
|
|
280
|
+
*/
|
|
281
|
+
export function parseAguiFrame(part) {
|
|
282
|
+
if (!isAguiFramePart(part))
|
|
283
|
+
throw new AguiVocabularyError(`not an AG-UI frame: expected kind ${JSON.stringify(AGUI_FRAME_KIND)}, got ` +
|
|
284
|
+
`${JSON.stringify(part?.kind ?? null)}. Route with ` +
|
|
285
|
+
`isAguiFramePart before calling this.`);
|
|
286
|
+
const f = part;
|
|
287
|
+
if (f.protocol !== AGUI_PROTOCOL)
|
|
288
|
+
throw new AguiVocabularyError(`AG-UI protocol mismatch: this consumer understands ${JSON.stringify(AGUI_PROTOCOL)}, the ` +
|
|
289
|
+
`frame declares ${JSON.stringify(f.protocol ?? null)}. Refusing rather than rendering the ` +
|
|
290
|
+
`fields that happen to still parse.`);
|
|
291
|
+
for (const field of ["threadId", "runId", "epoch"])
|
|
292
|
+
if (typeof f[field] !== "string" || f[field].length === 0)
|
|
293
|
+
throw new AguiVocabularyError(`frame ${field} must be a non-empty string`);
|
|
294
|
+
if (!Number.isSafeInteger(f.seq) || f.seq < 0)
|
|
295
|
+
throw new AguiVocabularyError(`frame seq must be a non-negative safe integer, got ${JSON.stringify(f.seq ?? null)}`);
|
|
296
|
+
if (!Array.isArray(f.events) || f.events.length === 0)
|
|
297
|
+
throw new AguiVocabularyError("a frame must carry at least one event");
|
|
298
|
+
const known = new Set(Object.values(AGUI_EVENT_TYPE));
|
|
299
|
+
f.events.forEach((e, i) => {
|
|
300
|
+
if (typeof e !== "object" || e === null)
|
|
301
|
+
throw new AguiVocabularyError(`frame events[${i}] is not an object`);
|
|
302
|
+
const t = e.type;
|
|
303
|
+
if (typeof t !== "string" || !known.has(t))
|
|
304
|
+
throw new AguiVocabularyError(`frame events[${i}] carries an unrecognised type ${JSON.stringify(t ?? null)}. A renderer ` +
|
|
305
|
+
`must refuse an event it cannot display rather than skip it: a skipped event is a hole ` +
|
|
306
|
+
`in a transcript that still looks complete.`);
|
|
307
|
+
});
|
|
308
|
+
return part;
|
|
309
|
+
}
|
|
310
|
+
// ---------------------------------------------------------------------------------------------
|
|
311
|
+
// Constructors.
|
|
312
|
+
//
|
|
313
|
+
// One per mapped event. They exist so a connector never hand-builds an object literal with a
|
|
314
|
+
// `type` string in it — the drift that produces is invisible until a consumer rejects a frame,
|
|
315
|
+
// which on this plane means after it is durably published.
|
|
316
|
+
//
|
|
317
|
+
// `timestamp` is deliberately a REQUIRED parameter on every constructor rather than defaulted to
|
|
318
|
+
// `Date.now()`. The plan's rule is that a timestamp is real or honestly labelled, and a default
|
|
319
|
+
// would silently manufacture an arrival time that looks like a source time. A caller with no
|
|
320
|
+
// source timestamp passes the arrival time AND sets `cotal.tsSource: "arrival"`.
|
|
321
|
+
// ---------------------------------------------------------------------------------------------
|
|
322
|
+
/** `RUN_STARTED` — `threadId` is the native session, `runId` one native harness turn. */
|
|
323
|
+
export function runStarted(o) {
|
|
324
|
+
return {
|
|
325
|
+
type: AGUI_EVENT_TYPE.RUN_STARTED,
|
|
326
|
+
threadId: o.threadId,
|
|
327
|
+
runId: o.runId,
|
|
328
|
+
timestamp: o.timestamp,
|
|
329
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The `interrupt` outcome's list, checked against exactly what the real schema requires.
|
|
334
|
+
*
|
|
335
|
+
* Measured: the list must be present and NON-EMPTY (`too_small` on `outcome.interrupts`), and every
|
|
336
|
+
* entry must be an object carrying `id` and `reason` as strings (`invalid_type` on
|
|
337
|
+
* `outcome.interrupts.<i>.<field>`). The check is exactly the schema's rule and no stricter: an
|
|
338
|
+
* empty `id` is legal upstream, so refusing it here would be this file inventing a protocol.
|
|
339
|
+
*
|
|
340
|
+
* It lives at the CONSTRUCTOR because the constructor is the only writer, and because a typed
|
|
341
|
+
* parameter proves nothing about the callers that actually reach it: a hook payload crosses into
|
|
342
|
+
* this file as `unknown`, and a replayed record crosses through a cast. The type is the reader's
|
|
343
|
+
* documentation, this is the enforcement.
|
|
344
|
+
*/
|
|
345
|
+
function assertInterrupts(list) {
|
|
346
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
347
|
+
throw new AguiVocabularyError("outcome.interrupts must be a non-empty array when the outcome is an interrupt");
|
|
348
|
+
for (const [i, entry] of list.entries()) {
|
|
349
|
+
const e = entry;
|
|
350
|
+
if (typeof e !== "object" || e === null)
|
|
351
|
+
throw new AguiVocabularyError(`outcome.interrupts[${i}] is not an object`);
|
|
352
|
+
if (typeof e.id !== "string")
|
|
353
|
+
throw new AguiVocabularyError(`outcome.interrupts[${i}].id must be a string`);
|
|
354
|
+
if (typeof e.reason !== "string")
|
|
355
|
+
throw new AguiVocabularyError(`outcome.interrupts[${i}].reason must be a string`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* `RUN_FINISHED`.
|
|
360
|
+
*
|
|
361
|
+
* `outcome` is OPTIONAL — measured against the real schema, which accepts a `RUN_FINISHED` carrying
|
|
362
|
+
* none. That matters because the Claude `Stop` hook reports that a turn ended and nothing more, so
|
|
363
|
+
* manufacturing a `success` outcome would be asserting something the source never said. When an
|
|
364
|
+
* outcome IS supplied its discriminator key is `type`, not `status` (measured: the schema refuses
|
|
365
|
+
* `{status:"success"}` naming `outcome.type`), and the object is STRICT.
|
|
366
|
+
*
|
|
367
|
+
* The `interrupt` outcome is REPRESENTABLE and VALIDATED here, and UNSPENT: no source on this plane
|
|
368
|
+
* constructs one, because a harness-native park is what would justify it and none of the three
|
|
369
|
+
* sources reports one. It is checked anyway because the arm exists, and an arm that builds an event
|
|
370
|
+
* the schema refuses is worse than an arm that does not exist. Its first draft took `unknown[]` and
|
|
371
|
+
* passed it through, so `[]` and `[{}]` both produced a refused event with nothing looking.
|
|
372
|
+
*/
|
|
373
|
+
export function runFinished(o) {
|
|
374
|
+
if (o.outcome?.type === "interrupt")
|
|
375
|
+
assertInterrupts(o.outcome.interrupts);
|
|
376
|
+
return {
|
|
377
|
+
type: AGUI_EVENT_TYPE.RUN_FINISHED,
|
|
378
|
+
threadId: o.threadId,
|
|
379
|
+
runId: o.runId,
|
|
380
|
+
timestamp: o.timestamp,
|
|
381
|
+
...(o.outcome ? { outcome: o.outcome } : {}),
|
|
382
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** `RUN_ERROR` — carries `message` and an optional `code`, and NO `runId` of its own. */
|
|
386
|
+
export function runError(o) {
|
|
387
|
+
return {
|
|
388
|
+
type: AGUI_EVENT_TYPE.RUN_ERROR,
|
|
389
|
+
message: o.message,
|
|
390
|
+
timestamp: o.timestamp,
|
|
391
|
+
...(o.code ? { code: o.code } : {}),
|
|
392
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* `TEXT_MESSAGE_START`.
|
|
397
|
+
*
|
|
398
|
+
* `messageId` must be unique per OBSERVATION, not per provider message. The specified form is
|
|
399
|
+
* `${entry.uuid}#${blockIndex}` — the provider's own id is preserved as `cotal.providerMessageId`
|
|
400
|
+
* and never spent here, because it does not have the cardinality the field needs.
|
|
401
|
+
*/
|
|
402
|
+
export function textMessageStart(o) {
|
|
403
|
+
return {
|
|
404
|
+
type: AGUI_EVENT_TYPE.TEXT_MESSAGE_START,
|
|
405
|
+
messageId: o.messageId,
|
|
406
|
+
timestamp: o.timestamp,
|
|
407
|
+
...(o.role ? { role: o.role } : {}),
|
|
408
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** `TEXT_MESSAGE_CONTENT` — one settled observation, never a token-level delta. */
|
|
412
|
+
export function textMessageContent(o) {
|
|
413
|
+
return {
|
|
414
|
+
type: AGUI_EVENT_TYPE.TEXT_MESSAGE_CONTENT,
|
|
415
|
+
messageId: o.messageId,
|
|
416
|
+
delta: o.delta,
|
|
417
|
+
timestamp: o.timestamp,
|
|
418
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/** `TEXT_MESSAGE_END`. */
|
|
422
|
+
export function textMessageEnd(o) {
|
|
423
|
+
return {
|
|
424
|
+
type: AGUI_EVENT_TYPE.TEXT_MESSAGE_END,
|
|
425
|
+
messageId: o.messageId,
|
|
426
|
+
timestamp: o.timestamp,
|
|
427
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** `TOOL_CALL_START` — `toolCallId` is the harness's own id, carried rather than re-minted. */
|
|
431
|
+
export function toolCallStart(o) {
|
|
432
|
+
return {
|
|
433
|
+
type: AGUI_EVENT_TYPE.TOOL_CALL_START,
|
|
434
|
+
toolCallId: o.toolCallId,
|
|
435
|
+
toolCallName: o.toolCallName,
|
|
436
|
+
timestamp: o.timestamp,
|
|
437
|
+
...(o.parentMessageId ? { parentMessageId: o.parentMessageId } : {}),
|
|
438
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* `TOOL_CALL_ARGS` — `delta` is the FULL `JSON.stringify(input)`.
|
|
443
|
+
*
|
|
444
|
+
* This is where `tr-`'s `salient()` died: it guessed which argument mattered and dropped the rest,
|
|
445
|
+
* so a reader could not reconstruct what the agent actually did. The whole input goes on the wire,
|
|
446
|
+
* and if it physically cannot fit, the sizing path truncates it with a label rather than silently.
|
|
447
|
+
*/
|
|
448
|
+
export function toolCallArgs(o) {
|
|
449
|
+
return {
|
|
450
|
+
type: AGUI_EVENT_TYPE.TOOL_CALL_ARGS,
|
|
451
|
+
toolCallId: o.toolCallId,
|
|
452
|
+
delta: o.delta,
|
|
453
|
+
timestamp: o.timestamp,
|
|
454
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** `TOOL_CALL_END`. */
|
|
458
|
+
export function toolCallEnd(o) {
|
|
459
|
+
return {
|
|
460
|
+
type: AGUI_EVENT_TYPE.TOOL_CALL_END,
|
|
461
|
+
toolCallId: o.toolCallId,
|
|
462
|
+
timestamp: o.timestamp,
|
|
463
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* `TOOL_CALL_RESULT`.
|
|
468
|
+
*
|
|
469
|
+
* **`messageId` is REQUIRED by the real schema** — measured; a result without one is refused. The
|
|
470
|
+
* plan's per-connector mapping table names only `toolCallId` for this row, so the identity of the
|
|
471
|
+
* result MESSAGE is unstated there and is raised as a plan gap rather than guessed at a call site.
|
|
472
|
+
* The parameter is required here so a mapper cannot omit it and discover the refusal downstream.
|
|
473
|
+
*
|
|
474
|
+
* `is_error` has no AG-UI field and rides `cotal.isError`.
|
|
475
|
+
*/
|
|
476
|
+
export function toolCallResult(o) {
|
|
477
|
+
return {
|
|
478
|
+
type: AGUI_EVENT_TYPE.TOOL_CALL_RESULT,
|
|
479
|
+
messageId: o.messageId,
|
|
480
|
+
toolCallId: o.toolCallId,
|
|
481
|
+
content: o.content,
|
|
482
|
+
timestamp: o.timestamp,
|
|
483
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* `REASONING_MESSAGE_START` — off by default; the signature is never emitted, ever.
|
|
488
|
+
*
|
|
489
|
+
* **`role` is a REQUIRED literal `"reasoning"`**, unlike `TEXT_MESSAGE_START` where `role` is
|
|
490
|
+
* optional. Measured: the first version of this constructor omitted it and the real schema refused
|
|
491
|
+
* the event. It is set here rather than exposed as a parameter, because there is exactly one legal
|
|
492
|
+
* value and a caller-supplied one could only ever be wrong.
|
|
493
|
+
*/
|
|
494
|
+
export function reasoningMessageStart(o) {
|
|
495
|
+
return {
|
|
496
|
+
type: AGUI_EVENT_TYPE.REASONING_MESSAGE_START,
|
|
497
|
+
messageId: o.messageId,
|
|
498
|
+
role: "reasoning",
|
|
499
|
+
timestamp: o.timestamp,
|
|
500
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** `REASONING_MESSAGE_CONTENT`. */
|
|
504
|
+
export function reasoningMessageContent(o) {
|
|
505
|
+
return {
|
|
506
|
+
type: AGUI_EVENT_TYPE.REASONING_MESSAGE_CONTENT,
|
|
507
|
+
messageId: o.messageId,
|
|
508
|
+
delta: o.delta,
|
|
509
|
+
timestamp: o.timestamp,
|
|
510
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/** `REASONING_MESSAGE_END`. */
|
|
514
|
+
export function reasoningMessageEnd(o) {
|
|
515
|
+
return {
|
|
516
|
+
type: AGUI_EVENT_TYPE.REASONING_MESSAGE_END,
|
|
517
|
+
messageId: o.messageId,
|
|
518
|
+
timestamp: o.timestamp,
|
|
519
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
// ---------------------------------------------------------------------------------------------
|
|
523
|
+
// Sizing and splitting.
|
|
524
|
+
//
|
|
525
|
+
// THIS DOES NOT MEASURE ANYTHING ITSELF, AND THAT IS THE WHOLE DESIGN. The bytes a frame puts on
|
|
526
|
+
// the wire are decided by the surface that builds the envelope and sets the headers: the endpoint
|
|
527
|
+
// adds `id`, `ts`, `space`, `from` and `channel` AFTER the publish call, and the JetStream client
|
|
528
|
+
// adds `Nats-Msg-Id` and `Nats-Expected-Last-Subject-Sequence`, all of which the broker charges
|
|
529
|
+
// against `max_payload`. A splitter that sized the frame from here would be measuring the FRAME
|
|
530
|
+
// while the broker measures the MESSAGE — and it would be wrong in the dangerous direction, because
|
|
531
|
+
// the part it produced would be REJECTED, and a rejected truncation makes the loss silent again,
|
|
532
|
+
// which is the exact failure splitting exists to prevent. Measured against a 4096-byte broker, a
|
|
533
|
+
// 3994-byte payload was refused while naive arithmetic said it fit by a hundred bytes.
|
|
534
|
+
//
|
|
535
|
+
// So `measure` is injected. In production it is `CotalEndpoint.encodedSize` bound to the real
|
|
536
|
+
// channel and expectation; in a cell it is any function, which is what makes the algorithm testable
|
|
537
|
+
// without a broker. Two places that both compute size WILL drift, and the drift is invisible until
|
|
538
|
+
// a frame near the ceiling meets a real broker — the one case no unit test builds.
|
|
539
|
+
// ---------------------------------------------------------------------------------------------
|
|
540
|
+
/**
|
|
541
|
+
* The fields a too-large event may be truncated on: NAMED, never inferred.
|
|
542
|
+
*
|
|
543
|
+
* Inferring "the biggest string on the object" would eventually truncate an id, a role or a tool
|
|
544
|
+
* name, and produce a frame that fits and means something else. Exactly three fields carry
|
|
545
|
+
* free-form content, so three is what this table holds; an event carrying none of them cannot be
|
|
546
|
+
* truncated and says so.
|
|
547
|
+
*/
|
|
548
|
+
const TRUNCATABLE_FIELDS = [
|
|
549
|
+
{ type: AGUI_EVENT_TYPE.TOOL_CALL_ARGS, field: "delta" },
|
|
550
|
+
{ type: AGUI_EVENT_TYPE.TOOL_CALL_RESULT, field: "content" },
|
|
551
|
+
{ type: AGUI_EVENT_TYPE.TEXT_MESSAGE_CONTENT, field: "delta" },
|
|
552
|
+
];
|
|
553
|
+
/** Truncate to `codePoints` code points, never code UNITS. Slicing a JS string by index can cut a
|
|
554
|
+
* surrogate pair in half and produce a lone surrogate, which is not well-formed UTF-16 — the exact
|
|
555
|
+
* defect `fix(core)!: refuse names that are not well-formed UTF-16` landed on this branch for. A
|
|
556
|
+
* splitter that reintroduced it here would emit a frame the wire layer is now obliged to refuse. */
|
|
557
|
+
const takeCodePoints = (s, codePoints) => Array.from(s).slice(0, codePoints).join("");
|
|
558
|
+
/**
|
|
559
|
+
* Split `events` into as many frames as the wire requires, truncating only what physically cannot
|
|
560
|
+
* cross it, and LABELLING every truncation.
|
|
561
|
+
*
|
|
562
|
+
* **THIS IS THE PREVIEW PLANE'S SPLITTER, AND IT HAS NO DURABLE-PLANE CALLER BY DESIGN.**
|
|
563
|
+
* Read that as a boundary, not as an oversight: the durable emitter packs with {@link packUnits} at
|
|
564
|
+
* SOURCE-RECORD boundaries and refuses an oversized unit, because one durable emit unit must be one
|
|
565
|
+
* frame carrying a cursor that resumes after it, and a frame ending mid-record has no cursor it can
|
|
566
|
+
* honestly store. This event-boundary split and its labelled truncation were specified before the
|
|
567
|
+
* durable plane had a cursor contract; where the two disagree, the durable plane's rule wins. The
|
|
568
|
+
* PREVIEW plane has no resume obligation at all, which is exactly where truncate-and-label is the
|
|
569
|
+
* right answer and why this machinery is worth keeping.
|
|
570
|
+
*
|
|
571
|
+
* **Calling this from the durable emitter would be a silent-loss bug**, not a performance choice —
|
|
572
|
+
* so if you are here looking for the packer, you want `packUnits`. And it is marked rather than
|
|
573
|
+
* deleted for the reason one module over already demonstrated: `assertExpectationSemantics()` sat
|
|
574
|
+
* with zero production callers looking exactly like live code, and unreachable code that looks live
|
|
575
|
+
* is a hazard whichever direction the next reader resolves it in.
|
|
576
|
+
*
|
|
577
|
+
* **Say the uncomfortable thing:** this is a content truncation, which is one of the
|
|
578
|
+
* sins `tr-` is being abolished for. The difference is not that we are gentler about it. `tr-` cut
|
|
579
|
+
* *every* result at 700 characters, silently and unconditionally, as a design choice; this cuts only
|
|
580
|
+
* what cannot physically be sent, three orders of magnitude higher, and records what it cut and how
|
|
581
|
+
* big it was. If routine results start tripping the ceiling the honest response is a
|
|
582
|
+
* content-addressed side channel, not a quieter limit.
|
|
583
|
+
*
|
|
584
|
+
* **Splitting happens on EVENT boundaries**, each part carrying its own `seq`, so a run may legally
|
|
585
|
+
* open in one frame and close in the next. That is why {@link AguiBrackets} checks the writer's
|
|
586
|
+
* stream and not the frame — a per-frame balance check would forbid the split this function
|
|
587
|
+
* performs.
|
|
588
|
+
*
|
|
589
|
+
* **`seq` is measured, not assumed.** Each candidate is measured at the `seq` it will actually carry,
|
|
590
|
+
* because `seq` is a header-adjacent value in the encoded body: sizing at 9 and publishing at 10 is
|
|
591
|
+
* one byte, and a frame one byte over the ceiling is refused. The same reason `encodedSize` takes
|
|
592
|
+
* `expectedLastSubjectSeq` as a parameter rather than sizing at zero.
|
|
593
|
+
*
|
|
594
|
+
* @param measure the EXACT encoded size of a candidate, headers included — `CotalEndpoint.encodedSize`
|
|
595
|
+
* in production. Never re-implement it here.
|
|
596
|
+
* @param limit the broker's `max_payload`.
|
|
597
|
+
* @throws {AguiVocabularyError} if a single event cannot be made to fit even fully truncated, or
|
|
598
|
+
* carries no truncatable field. Failing loud is required: the alternative is looping forever or
|
|
599
|
+
* dropping the event, and a dropped event on this plane is the silent loss the plane exists to
|
|
600
|
+
* make impossible.
|
|
601
|
+
*/
|
|
602
|
+
export function splitFrames(opts) {
|
|
603
|
+
if (!Number.isSafeInteger(opts.limit) || opts.limit <= 0)
|
|
604
|
+
throw new AguiVocabularyError(`split limit must be a positive safe integer, got ${JSON.stringify(opts.limit)}`);
|
|
605
|
+
if (!Array.isArray(opts.events) || opts.events.length === 0)
|
|
606
|
+
throw new AguiVocabularyError("splitFrames requires at least one event");
|
|
607
|
+
const { threadId, runId, epoch, measure, limit } = opts;
|
|
608
|
+
const build = (seq, events) => aguiFrame({ threadId, runId, epoch, seq, events });
|
|
609
|
+
const fits = (seq, events) => measure(build(seq, events)) <= limit;
|
|
610
|
+
const out = [];
|
|
611
|
+
let seq = opts.firstSeq;
|
|
612
|
+
let batch = [];
|
|
613
|
+
const flush = () => {
|
|
614
|
+
if (batch.length === 0)
|
|
615
|
+
return;
|
|
616
|
+
out.push(build(seq, batch));
|
|
617
|
+
seq += 1;
|
|
618
|
+
batch = [];
|
|
619
|
+
};
|
|
620
|
+
for (const event of opts.events) {
|
|
621
|
+
if (fits(seq, [...batch, event])) {
|
|
622
|
+
batch.push(event);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// It did not fit alongside what is already batched. Close the batch and reconsider the event
|
|
626
|
+
// ALONE — an event that is merely unlucky in its neighbours needs no truncation at all, and
|
|
627
|
+
// truncating it here would cut content that would have crossed the wire intact.
|
|
628
|
+
flush();
|
|
629
|
+
if (fits(seq, [event])) {
|
|
630
|
+
batch.push(event);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
batch.push(truncateToFit(event, seq, fits, limit, measure, build));
|
|
634
|
+
}
|
|
635
|
+
flush();
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Shrink one event's single largest truncatable string until the frame carrying it alone fits.
|
|
640
|
+
*
|
|
641
|
+
* **Iterate to fit, never one pass.** Shortening a string changes its JSON escaping and
|
|
642
|
+
* its UTF-8 length NONLINEARLY — one multi-byte character or one escaped quote is several bytes, so
|
|
643
|
+
* "cut it to the overage" both overshoots and undershoots depending on content. This binary-searches
|
|
644
|
+
* the code-point length and re-measures the WHOLE candidate each step, envelope and headers
|
|
645
|
+
* included, so the answer is measured rather than computed.
|
|
646
|
+
*
|
|
647
|
+
* The shortened value carries no ellipsis or marker. The label is `cotal.truncated`, which records
|
|
648
|
+
* the field path AND the original byte count — a marker inside the value would spend wire budget to
|
|
649
|
+
* say less, and a consumer parsing the field as JSON (`TOOL_CALL_ARGS.delta` is a JSON fragment)
|
|
650
|
+
* would have to strip it.
|
|
651
|
+
*/
|
|
652
|
+
function truncateToFit(event, seq, fits, limit, measure, build) {
|
|
653
|
+
const record = event;
|
|
654
|
+
const spec = TRUNCATABLE_FIELDS.find((t) => t.type === record.type && typeof record[t.field] === "string");
|
|
655
|
+
if (!spec)
|
|
656
|
+
throw new AguiVocabularyError(`a ${String(record.type)} event does not fit in ${limit} bytes and carries no truncatable ` +
|
|
657
|
+
`field. Measured ${measure(build(seq, [event]))} bytes for the frame carrying it alone. ` +
|
|
658
|
+
`Only ${TRUNCATABLE_FIELDS.map((t) => `${t.type}.${t.field}`).join(", ")} may be cut, ` +
|
|
659
|
+
`because cutting anything else would produce a frame that fits and means something else.`);
|
|
660
|
+
const original = record[spec.field];
|
|
661
|
+
const originalBytes = Buffer.byteLength(original, "utf8");
|
|
662
|
+
const at = (codePoints) => ({
|
|
663
|
+
...record,
|
|
664
|
+
[spec.field]: takeCodePoints(original, codePoints),
|
|
665
|
+
cotal: {
|
|
666
|
+
...(record.cotal ?? {}),
|
|
667
|
+
truncated: { field: `${String(record.type)}.${spec.field}`, originalBytes },
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
// Even emptied it must fit, or no truncation can help and looping would be the alternative. This
|
|
671
|
+
// is also the plan's "fixed envelope and headers alone exceed the ceiling" case, detected by
|
|
672
|
+
// measurement rather than by a second arithmetic path that could disagree with the first.
|
|
673
|
+
if (!fits(seq, [at(0)]))
|
|
674
|
+
throw new AguiVocabularyError(`a ${String(record.type)} event does not fit in ${limit} bytes even with ${spec.field} ` +
|
|
675
|
+
`emptied — the envelope, the labelling metadata and the headers alone are ` +
|
|
676
|
+
`${measure(build(seq, [at(0)]))} bytes. No truncation can help, so this fails loudly ` +
|
|
677
|
+
`rather than dropping the event or looping.`);
|
|
678
|
+
// Binary search the largest code-point count that still fits. `lo` always fits, `hi` never does.
|
|
679
|
+
let lo = 0;
|
|
680
|
+
let hi = Array.from(original).length + 1;
|
|
681
|
+
while (hi - lo > 1) {
|
|
682
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
683
|
+
if (fits(seq, [at(mid)]))
|
|
684
|
+
lo = mid;
|
|
685
|
+
else
|
|
686
|
+
hi = mid;
|
|
687
|
+
}
|
|
688
|
+
return at(lo);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The emitter has stopped and will not publish again without operator action.
|
|
692
|
+
*
|
|
693
|
+
* Halting is a SUCCESS of this design, not a failure of it: every halt below is a case where the
|
|
694
|
+
* alternative is to report success for a message that was not stored, or to fold an ack for a body
|
|
695
|
+
* we did not write. A halt is loud, bounded and recoverable by a human; the alternative is silent
|
|
696
|
+
* and permanent.
|
|
697
|
+
*/
|
|
698
|
+
/**
|
|
699
|
+
* A bracket violation that is OURS, not the writer's: the machine that tracks open runs and messages
|
|
700
|
+
* was lost across a process restart.
|
|
701
|
+
*
|
|
702
|
+
* **This exists because two halts that both say "unbalanced" prove nothing about which produced
|
|
703
|
+
* one.** The WAL persists `epoch`, `frontier` and the pending frame, and NOT the set of open
|
|
704
|
+
* runs and messages, so a process that dies mid-run restarts with an empty {@link AguiBrackets},
|
|
705
|
+
* resumes from `sourceCursor` at events whose `RUN_STARTED` was already published, and refuses the
|
|
706
|
+
* first of them. Without this class the operator sees "nothing may be emitted outside an open run"
|
|
707
|
+
* and files a bug against a writer that did nothing wrong.
|
|
708
|
+
*
|
|
709
|
+
* It is deliberately a SUBCLASS: every existing catch of {@link AguiVocabularyError} still catches
|
|
710
|
+
* it, and only code that wants to tell the two apart has to know it exists.
|
|
711
|
+
*/
|
|
712
|
+
export class AguiBracketStateLost extends AguiVocabularyError {
|
|
713
|
+
cause;
|
|
714
|
+
constructor(message, cause) {
|
|
715
|
+
super(message);
|
|
716
|
+
this.cause = cause;
|
|
717
|
+
this.name = "AguiBracketStateLost";
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
export class AguiEmitterHalted extends Error {
|
|
721
|
+
reason;
|
|
722
|
+
constructor(reason, message) {
|
|
723
|
+
super(message);
|
|
724
|
+
this.reason = reason;
|
|
725
|
+
this.name = "AguiEmitterHalted";
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The id used only for SIZING, and it is the longest one `assertIdToken` admits.
|
|
730
|
+
*
|
|
731
|
+
* Sizing must never report a smaller number than publishing will produce, and the id is not known
|
|
732
|
+
* when a frame is measured — it is minted per publish attempt. Measuring at the maximum admissible
|
|
733
|
+
* length makes the measurement an UPPER BOUND over every id the emitter could mint, which costs a
|
|
734
|
+
* few bytes of packing density and removes an entire class of near-ceiling defect. The alternative,
|
|
735
|
+
* measuring with the id we intend to use, requires minting before packing and freezing an id for a
|
|
736
|
+
* frame that may never be built.
|
|
737
|
+
*/
|
|
738
|
+
const SIZING_ID = "S".repeat(64);
|
|
739
|
+
/**
|
|
740
|
+
* Likewise for the expectation, and this one CANNOT be known at pack time even in principle.
|
|
741
|
+
*
|
|
742
|
+
* `expectedLastSubjectSeq` for frame k+1 is the sequence the broker assigns frame k, and the stream
|
|
743
|
+
* sequence advances with every message in the space, not only ours. Its decimal length is therefore
|
|
744
|
+
* unknowable while packing. `MAX_SAFE_INTEGER` is the widest value the publish path will accept, so
|
|
745
|
+
* measuring at it bounds every expectation the emitter can ever send.
|
|
746
|
+
*/
|
|
747
|
+
const SIZING_EXPECTATION = Number.MAX_SAFE_INTEGER;
|
|
748
|
+
/**
|
|
749
|
+
* Pack units into frames, splitting ONLY at unit boundaries and never inside one.
|
|
750
|
+
*
|
|
751
|
+
* Deliberately NOT {@link splitFrames}, and the difference is the durable plane's one-unit-one-frame
|
|
752
|
+
* rule. `splitFrames` splits at EVENT boundaries, which is the right answer for a frame considered
|
|
753
|
+
* on its own, but a frame that ends mid-record has no cursor it can honestly store: the only value
|
|
754
|
+
* available says the whole record was consumed, and folding that after a crash skips the rest of the
|
|
755
|
+
* record's events with no `seq` gap for a consumer to notice.
|
|
756
|
+
*
|
|
757
|
+
* **So the event-boundary split and the one-unit-one-frame rule are in tension, and this resolves it
|
|
758
|
+
* in the direction the durable plane requires: a single unit that does not fit FAILS LOUD rather
|
|
759
|
+
* than being truncated at a frame boundary.** That leaves `splitFrames`'s truncation path with no
|
|
760
|
+
* caller on the durable plane, which is reported as a design conflict rather than decided here.
|
|
761
|
+
*
|
|
762
|
+
* @throws {AguiVocabularyError} when one unit cannot fit in a frame alone.
|
|
763
|
+
*/
|
|
764
|
+
export function packUnits(opts) {
|
|
765
|
+
const { threadId, epoch, measure, limit } = opts;
|
|
766
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
767
|
+
throw new AguiVocabularyError(`pack limit must be a positive safe integer, got ${JSON.stringify(limit)}`);
|
|
768
|
+
const out = [];
|
|
769
|
+
let seq = opts.firstSeq;
|
|
770
|
+
let batch = [];
|
|
771
|
+
let batchRun;
|
|
772
|
+
let batchCursor;
|
|
773
|
+
const flush = () => {
|
|
774
|
+
if (batch.length === 0)
|
|
775
|
+
return;
|
|
776
|
+
out.push({ frame: aguiFrame({ threadId, runId: batchRun, epoch, seq, events: batch }), cursor: batchCursor });
|
|
777
|
+
seq += 1;
|
|
778
|
+
batch = [];
|
|
779
|
+
batchRun = undefined;
|
|
780
|
+
batchCursor = undefined;
|
|
781
|
+
};
|
|
782
|
+
for (const unit of opts.units) {
|
|
783
|
+
if (unit.events.length === 0)
|
|
784
|
+
throw new AguiVocabularyError("packUnits was handed an empty unit; a record that maps to nothing advances the cursor and never becomes a frame");
|
|
785
|
+
// A frame names ONE run. A unit from a different run cannot join the open batch even if it
|
|
786
|
+
// would fit, so the run change is a flush and not a size decision.
|
|
787
|
+
if (batchRun !== undefined && unit.runId !== batchRun)
|
|
788
|
+
flush();
|
|
789
|
+
const candidate = [...batch, ...unit.events];
|
|
790
|
+
const fits = measure(aguiFrame({ threadId, runId: batchRun ?? unit.runId, epoch, seq, events: candidate })) <= limit;
|
|
791
|
+
if (fits) {
|
|
792
|
+
batch = candidate;
|
|
793
|
+
batchRun = batchRun ?? unit.runId;
|
|
794
|
+
batchCursor = unit.cursor;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
// It did not fit WITH the open batch. Flush and try it alone before concluding anything about
|
|
798
|
+
// the unit itself: an ordinary unit that happens to arrive behind a nearly-full frame is not an
|
|
799
|
+
// oversized unit, and treating it as one would halt the emitter on a packing accident.
|
|
800
|
+
flush();
|
|
801
|
+
const alone = aguiFrame({ threadId, runId: unit.runId, epoch, seq, events: unit.events });
|
|
802
|
+
const aloneBytes = measure(alone);
|
|
803
|
+
if (aloneBytes > limit)
|
|
804
|
+
throw new AguiVocabularyError(`a single source observation does not fit in one frame (${aloneBytes} > ${limit} bytes, ` +
|
|
805
|
+
`${unit.events.length} event(s), run ${unit.runId}). One source observation is one frame, ` +
|
|
806
|
+
`and that rule requires this to fail loud rather ` +
|
|
807
|
+
`than be truncated at a frame boundary: a frame that ends mid-record has no cursor it can ` +
|
|
808
|
+
`honestly store, and a dropped boundary with no gap marker is worse than a halt.`);
|
|
809
|
+
batch = [...unit.events];
|
|
810
|
+
batchRun = unit.runId;
|
|
811
|
+
batchCursor = unit.cursor;
|
|
812
|
+
}
|
|
813
|
+
flush();
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* The event emitter: one per principal, one thread at a time.
|
|
818
|
+
*
|
|
819
|
+
* **BRACKET STATE SURVIVES A RESTART, AND THIS PARAGRAPH USED TO SAY THE OPPOSITE.** It described a
|
|
820
|
+
* declared gap — an emitter coming back with an empty machine, resuming at events whose
|
|
821
|
+
* `RUN_STARTED` had already been published, and refusing the first of them — long after the WAL
|
|
822
|
+
* started persisting the machine. The words were true when they were written and stayed on the page
|
|
823
|
+
* through the change that falsified them, which is the failure mode a class header is worst at
|
|
824
|
+
* showing: it is the first thing a cutover author reads about recovery, and it was telling them to
|
|
825
|
+
* expect a halt the code no longer produces.
|
|
826
|
+
*
|
|
827
|
+
* What actually happens: {@link AguiBrackets} is a property of the WRITER'S STREAM across frames, so
|
|
828
|
+
* the WAL freezes the machine's state WITH each pending frame and promotes it on fold. A restart
|
|
829
|
+
* therefore reopens knowing exactly which run, messages and tool calls were open at the last FOLDED
|
|
830
|
+
* position, and {@link AguiBrackets.restore} continues from there rather than from empty.
|
|
831
|
+
*
|
|
832
|
+
* **The lost-state path still exists, and it is now the narrow case it should always have been:** a
|
|
833
|
+
* document that CANNOT SAY what was open. That is a WAL migrated from v1, which recorded no bracket
|
|
834
|
+
* state at all, and it loads as `null` rather than as an empty machine precisely so the difference
|
|
835
|
+
* stays visible. Only there does the emitter start empty, resume into an already-open run, and
|
|
836
|
+
* refuse the first event with {@link AguiBracketStateLost} — a halt rather than a loss, which is the
|
|
837
|
+
* safe direction, and diagnosed by name rather than surfacing as an anonymous protocol violation.
|
|
838
|
+
*/
|
|
839
|
+
export class AguiEmitter {
|
|
840
|
+
ep;
|
|
841
|
+
wal;
|
|
842
|
+
source;
|
|
843
|
+
map;
|
|
844
|
+
channel;
|
|
845
|
+
threadId;
|
|
846
|
+
/**
|
|
847
|
+
* The bracket machine AT THE FOLDED POSITION — deliberately not "wherever validation got to".
|
|
848
|
+
*
|
|
849
|
+
* It advances one frame at a time, immediately before that frame's `beginSend`, so the state
|
|
850
|
+
* frozen with a pending frame is the state that belongs to it. A machine advanced by the whole
|
|
851
|
+
* batch up front would freeze a state describing events that had not been sent.
|
|
852
|
+
*/
|
|
853
|
+
brackets;
|
|
854
|
+
halted;
|
|
855
|
+
/** True once THIS process has fed an event through the bracket machine. It is the half of the
|
|
856
|
+
* restart diagnosis that keeps a genuine mid-stream violation from being blamed on a restart. */
|
|
857
|
+
fedAnyEvent = false;
|
|
858
|
+
constructor(ep, wal, source, map,
|
|
859
|
+
/** Derived from the endpoint's OWN principal, never from a config name or the launch env. */
|
|
860
|
+
channel, threadId) {
|
|
861
|
+
this.ep = ep;
|
|
862
|
+
this.wal = wal;
|
|
863
|
+
this.source = source;
|
|
864
|
+
this.map = map;
|
|
865
|
+
this.channel = channel;
|
|
866
|
+
this.threadId = threadId;
|
|
867
|
+
// RESTORED FROM THE WAL, which is the whole point of the v2 migration: a process that died
|
|
868
|
+
// mid-run comes back knowing which runs and messages are open, instead of refusing the first
|
|
869
|
+
// event it re-reads. `null` means the document cannot say (migrated from v1) — an empty machine
|
|
870
|
+
// is the honest starting point there, and `diagnoseBracket` is what keeps the resulting refusal
|
|
871
|
+
// from being blamed on the writer.
|
|
872
|
+
this.brackets = wal.brackets ? AguiBrackets.restore(wal.brackets) : new AguiBrackets();
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Start an emitter: resolve the channel, run the single-replica preflight, and settle any pending
|
|
876
|
+
* frame.
|
|
877
|
+
*
|
|
878
|
+
* **THIS IS THAT PREFLIGHT'S PRODUCTION CALL SITE, AND UNTIL THIS FUNCTION EXISTED THERE WAS
|
|
879
|
+
* NONE.** `CotalEndpoint.assertExpectationSemantics()` had zero production callers: it was a
|
|
880
|
+
* check that shipped, was covered by its own suite, and never ran outside one. That is why it is
|
|
881
|
+
* called HERE, before recovery and therefore before any publish — a serialized append on an
|
|
882
|
+
* unverified stream is the exact case it exists to prevent, and doing it after recovery would
|
|
883
|
+
* leave the one publish that matters most, the re-publish of a frozen frame, outside the guard.
|
|
884
|
+
*/
|
|
885
|
+
static async start(opts) {
|
|
886
|
+
const { endpoint, wal } = opts;
|
|
887
|
+
const channel = eventChannelForSession(endpoint);
|
|
888
|
+
// The WAL must be THIS principal's. `EventWal.open` refuses a document whose stored principal
|
|
889
|
+
// disagrees with what it was asked for, but that protects the file against being mistaken for
|
|
890
|
+
// another; it cannot notice an emitter handed the wrong WAL object. Publishing under one
|
|
891
|
+
// principal's identity while recovering another's frozen `E` is a fabricated frontier.
|
|
892
|
+
const live = principalKey(endpoint.principal.owner, endpoint.principal.actor).key;
|
|
893
|
+
if (wal.principal !== live)
|
|
894
|
+
throw new Error(`event WAL belongs to principal ${wal.principal}, but this endpoint is ${live} — refusing to ` +
|
|
895
|
+
`publish under one identity from another's write-ahead log`);
|
|
896
|
+
// THE SINGLE-REPLICA PREFLIGHT: before recovery, before any publish.
|
|
897
|
+
await endpoint.assertExpectationSemantics();
|
|
898
|
+
const em = new AguiEmitter(endpoint, wal, opts.source, opts.map, channel, wal.threadId);
|
|
899
|
+
await em.recover();
|
|
900
|
+
return em;
|
|
901
|
+
}
|
|
902
|
+
/** True once the emitter has stopped for good. */
|
|
903
|
+
get stopped() {
|
|
904
|
+
return this.halted !== undefined;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Boot recovery, branching on the WAL's tag.
|
|
908
|
+
*
|
|
909
|
+
* `acked` NEVER republishes: the frame landed and we know it, so the only remaining work is to
|
|
910
|
+
* fold. `sent_unacked` is the genuinely uncertain case and republishes with the SAME frozen `id`
|
|
911
|
+
* and `E` — never the current tip, because re-deriving either is what turns an uncertain publish
|
|
912
|
+
* into a second, different message.
|
|
913
|
+
*/
|
|
914
|
+
async recover() {
|
|
915
|
+
const p = this.wal.pending;
|
|
916
|
+
if (!p)
|
|
917
|
+
return;
|
|
918
|
+
if (p.state === "acked") {
|
|
919
|
+
await this.wal.fold();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
await this.attempt({ id: p.id, E: p.E, body: p.body, retry: true });
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Read forward, map, pack, and publish. Returns what it did, so a caller can distinguish "nothing
|
|
926
|
+
* to do" from "did work" without inspecting the WAL.
|
|
927
|
+
*/
|
|
928
|
+
async pump() {
|
|
929
|
+
if (this.halted)
|
|
930
|
+
throw this.halted;
|
|
931
|
+
if (this.wal.pending)
|
|
932
|
+
throw new Error(`event emitter for ${this.channel}: a frame is still pending; recovery must settle it before a new read`);
|
|
933
|
+
const read = await this.source.read(this.wal.frontier.sourceCursor);
|
|
934
|
+
// Units the mapper dropped are not errors and not frames. Their cursor folds FORWARD into the
|
|
935
|
+
// preceding unit, so consuming that frame consumes them too and they are never re-read. A drop
|
|
936
|
+
// stays apart from a mapper error, which reaches the caller with the cursor unmoved.
|
|
937
|
+
const units = [];
|
|
938
|
+
for (const rec of read.records) {
|
|
939
|
+
const mapped = this.map(rec.value);
|
|
940
|
+
if (mapped === null || mapped.events.length === 0) {
|
|
941
|
+
const last = units[units.length - 1];
|
|
942
|
+
if (last)
|
|
943
|
+
last.cursor = rec.cursor;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
units.push({ runId: mapped.runId, events: mapped.events, cursor: rec.cursor });
|
|
947
|
+
}
|
|
948
|
+
// A bounded range that mapped to nothing advances the cursor atomically and ALONE.
|
|
949
|
+
//
|
|
950
|
+
// THIS IS THE COMMON PATH, NOT AN EDGE CASE, and the number is here so nobody reads it as one.
|
|
951
|
+
// Measured on a real Claude session of 5938 records: only 2694 (45%) carry a `message` at all —
|
|
952
|
+
// the rest are attachments, queue operations, mode changes, prompt markers and system entries
|
|
953
|
+
// the mapping deliberately drops. So the MAJORITY of a real session maps to nothing. An emitter
|
|
954
|
+
// advanced the cursor only through an acked frame would re-read the same 55% forever, and no
|
|
955
|
+
// fixture would ever show it, because a fixture author writes records that mean something.
|
|
956
|
+
//
|
|
957
|
+
// It must also advance on an ADOPT, which reads zero records by design: without that the position
|
|
958
|
+
// is never persisted and the next read adopts a LATER end, silently skipping everything appended
|
|
959
|
+
// in between.
|
|
960
|
+
if (units.length === 0) {
|
|
961
|
+
if (read.cursor !== this.wal.frontier.sourceCursor)
|
|
962
|
+
await this.wal.advanceCursorOnly(read.cursor);
|
|
963
|
+
return { frames: 0, events: 0 };
|
|
964
|
+
}
|
|
965
|
+
// Validate the WHOLE batch before publishing any of it. A vocabulary violation discovered
|
|
966
|
+
// halfway through would leave a valid prefix on the wire and the rest refused, and the refusal
|
|
967
|
+
// is supposed to mean "this stream never carried that", not "it carried some of it".
|
|
968
|
+
// Validated on a CLONE, so the machine that is in step with the disk does not advance for a
|
|
969
|
+
// batch that may never be sent. The clone starts from the folded state, so it sees exactly what
|
|
970
|
+
// the real machine will see, in the same order.
|
|
971
|
+
const probe = this.brackets.clone();
|
|
972
|
+
for (const u of units)
|
|
973
|
+
for (const e of u.events) {
|
|
974
|
+
try {
|
|
975
|
+
probe.accept(e);
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
throw this.diagnoseBracket(err);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// Set only after the WHOLE batch validated. Setting it per event would make a batch that failed
|
|
982
|
+
// on its FIRST event count as "this process has fed something", which is the precise input that
|
|
983
|
+
// turns the restart diagnosis off.
|
|
984
|
+
this.fedAnyEvent = true;
|
|
985
|
+
const frames = packUnits({
|
|
986
|
+
threadId: this.threadId,
|
|
987
|
+
epoch: this.wal.epoch,
|
|
988
|
+
firstSeq: this.wal.frontier.seq + 1,
|
|
989
|
+
units,
|
|
990
|
+
measure: (f) => this.measure(f),
|
|
991
|
+
limit: this.ep.maxPayload,
|
|
992
|
+
});
|
|
993
|
+
let events = 0;
|
|
994
|
+
for (const { frame, cursor } of frames) {
|
|
995
|
+
await this.publish(frame, cursor);
|
|
996
|
+
events += frame.events.length;
|
|
997
|
+
}
|
|
998
|
+
// Records the mapper dropped AFTER the last unit have no frame to ride on, so their cursor is
|
|
999
|
+
// advanced on its own: the same cursor-only rule, applied to the tail of the batch.
|
|
1000
|
+
if (read.cursor !== this.wal.frontier.sourceCursor)
|
|
1001
|
+
await this.wal.advanceCursorOnly(read.cursor);
|
|
1002
|
+
return { frames: frames.length, events };
|
|
1003
|
+
}
|
|
1004
|
+
/** Measure a candidate frame EXACTLY as the wire will, at an upper bound over id and expectation. */
|
|
1005
|
+
measure(frame) {
|
|
1006
|
+
return this.ep.encodedSize({
|
|
1007
|
+
channel: this.channel,
|
|
1008
|
+
parts: [frame],
|
|
1009
|
+
id: SIZING_ID,
|
|
1010
|
+
expectedLastSubjectSeq: SIZING_EXPECTATION,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
/** Transition 1 then the first network attempt. */
|
|
1014
|
+
async publish(frame, cursor) {
|
|
1015
|
+
// Advance the real machine by exactly this frame. It cannot throw: the identical sequence was
|
|
1016
|
+
// already accepted by a clone starting from this same state, in this same order. It is not
|
|
1017
|
+
// wrapped in a diagnosis for that reason — a throw here would be a bug in this file, not a
|
|
1018
|
+
// stream problem, and dressing it as one would hide it.
|
|
1019
|
+
for (const e of frame.events)
|
|
1020
|
+
this.brackets.accept(e);
|
|
1021
|
+
const brackets = this.brackets.snapshot();
|
|
1022
|
+
const id = randomUUID();
|
|
1023
|
+
const E = this.wal.frontier.lastSubjectSeq;
|
|
1024
|
+
const body = [frame];
|
|
1025
|
+
// Durable BEFORE the wire. The order is the whole state machine: a crash between this line and
|
|
1026
|
+
// the next is recoverable precisely because the id and `E` are already frozen on disk.
|
|
1027
|
+
await this.wal.beginSend({ id, E, seq: frame.seq, sourceCursor: cursor, body, brackets });
|
|
1028
|
+
await this.attempt({ id, E, body, retry: false });
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* One publish attempt — first or retry — with the FROZEN id and the FROZEN `E`. Never the tip.
|
|
1032
|
+
*
|
|
1033
|
+
* The three outcomes are not symmetric and the asymmetry is the design:
|
|
1034
|
+
* - `!duplicate` → transition 2 then 3. Success becomes durable before the frontier moves.
|
|
1035
|
+
* - `duplicate` → HALT. On a first attempt it means a body WE DID NOT WRITE holds our id, and
|
|
1036
|
+
* folding its `ackSeq` would advance the frontier and the source cursor past events that were
|
|
1037
|
+
* never published. On a retry it cannot happen on a single-replica stream at all, because such a
|
|
1038
|
+
* stream evaluates the expectation before the dedup cache, so observing it proves the stream is
|
|
1039
|
+
* not single-replica. Both are
|
|
1040
|
+
* fail-loud, and neither is a case where guessing is better than stopping.
|
|
1041
|
+
* - CAS loss → HALT. Someone else moved the tip on a subject only this principal may write, or
|
|
1042
|
+
* the subject was purged. Uncertainty plus a moved tip is exactly what must not be guessed at.
|
|
1043
|
+
*
|
|
1044
|
+
* A NETWORK error is deliberately none of these: it leaves `pending` as `sent_unacked`, which is
|
|
1045
|
+
* the state that means "we do not know", and the next boot retries the same frozen frame.
|
|
1046
|
+
*/
|
|
1047
|
+
async attempt(o) {
|
|
1048
|
+
let ack;
|
|
1049
|
+
try {
|
|
1050
|
+
({ ack } = await this.ep.multicastExpecting({
|
|
1051
|
+
channel: this.channel,
|
|
1052
|
+
parts: o.body,
|
|
1053
|
+
id: o.id,
|
|
1054
|
+
expectedLastSubjectSeq: o.E,
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
catch (e) {
|
|
1058
|
+
if (isCasLoss(e))
|
|
1059
|
+
throw this.halt("cas-loss", `event emitter for ${this.channel}: the subject tip is no longer ${o.E} (${e.message}). ` +
|
|
1060
|
+
`This subject is writable by one principal, so a moved tip means another writer, a restored ` +
|
|
1061
|
+
`stream, or a filtered purge — none of which a publisher may resolve by re-reading the tip. ` +
|
|
1062
|
+
`Clearing it is an explicit abandonment, which resets epoch, seq, E and cursor together.`);
|
|
1063
|
+
throw e;
|
|
1064
|
+
}
|
|
1065
|
+
if (ack.duplicate)
|
|
1066
|
+
throw this.halt("duplicate-ack", `event emitter for ${this.channel}: the broker answered ${o.retry ? "a RETRY" : "a FIRST attempt"} ` +
|
|
1067
|
+
`for id ${o.id} with duplicate:true. ` +
|
|
1068
|
+
(o.retry
|
|
1069
|
+
? `Under the SINGLE-REPLICA RETRY RULE this cannot happen on an R1 stream, which ` +
|
|
1070
|
+
`evaluates the subject expectation ` +
|
|
1071
|
+
`before the dedup cache — so either the stream is not R1 or a foreign body holds our ` +
|
|
1072
|
+
`stream-wide id. `
|
|
1073
|
+
: `We have never published this id, so a body we did not write holds it. `) +
|
|
1074
|
+
`Folding this ack would advance the frontier and the source cursor past events that were ` +
|
|
1075
|
+
`never published: silent loss of real events. The frontier and cursor are unchanged.`);
|
|
1076
|
+
await this.wal.recordAck(ack.seq);
|
|
1077
|
+
await this.wal.fold();
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Decide whether a bracket refusal is the WRITER's fault or OURS, and say which.
|
|
1081
|
+
*
|
|
1082
|
+
* Ours iff ALL THREE hold, and each is load-bearing:
|
|
1083
|
+
* - this process has fed NO event through the machine yet, so the machine cannot have been put
|
|
1084
|
+
* into a bad state by anything we did in this run; and
|
|
1085
|
+
* - the frontier is non-virgin, so frames — and therefore possibly an open `RUN_STARTED` — were
|
|
1086
|
+
* published by a PREVIOUS process; and
|
|
1087
|
+
* - the WAL cannot say what was open. Since v2 the machine is PERSISTED, so an ordinary restart
|
|
1088
|
+
* restores it and never reaches here at all; `null` means the document was migrated from v1 and
|
|
1089
|
+
* genuinely never recorded the state. Without this condition the diagnosis would survive as a
|
|
1090
|
+
* permanent excuse for a case the migration fixed.
|
|
1091
|
+
*
|
|
1092
|
+
* Drop the first condition and a genuine mid-stream violation by the writer gets blamed on a
|
|
1093
|
+
* restart that happened an hour ago. Drop the second and a violation on a virgin thread, where
|
|
1094
|
+
* nothing was ever published and nothing could have been lost, gets blamed on a restart that never
|
|
1095
|
+
* happened. Each condition alone produces a confident, wrong diagnosis — which is worse than the
|
|
1096
|
+
* undiagnosed error it replaced, because a named cause stops the search.
|
|
1097
|
+
*/
|
|
1098
|
+
diagnoseBracket(err) {
|
|
1099
|
+
if (this.fedAnyEvent || this.wal.frontier.seq === 0 || this.wal.brackets !== null)
|
|
1100
|
+
return err;
|
|
1101
|
+
return new AguiBracketStateLost(`event emitter for ${this.channel}: bracket state was LOST ACROSS A RESTART — this is not a ` +
|
|
1102
|
+
`protocol violation by the writer. This process has emitted nothing yet, but the WAL says ` +
|
|
1103
|
+
`frame ${this.wal.frontier.seq} already went out, and the document records NO bracket state ` +
|
|
1104
|
+
`(it was migrated from v1, which never stored one), so any run or message the previous ` +
|
|
1105
|
+
`process left open is invisible to this one. Resuming from the source cursor therefore lands ` +
|
|
1106
|
+
`mid-run and the first event is refused. A WAL written by this build persists the machine and ` +
|
|
1107
|
+
`does not reach this path. The underlying refusal was: ${err.message}`, err);
|
|
1108
|
+
}
|
|
1109
|
+
halt(reason, message) {
|
|
1110
|
+
this.halted = new AguiEmitterHalted(reason, message);
|
|
1111
|
+
return this.halted;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
//# sourceMappingURL=agui.js.map
|