@cotal-ai/connector-core 0.67.0 → 0.69.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/agui.js CHANGED
@@ -23,17 +23,16 @@
23
23
  * ## What is here, and what is deliberately NOT
24
24
  *
25
25
  * Here: the event constructors, the frame envelope, the routing/validity split
26
- * ({@link isAguiFramePart} / {@link parseAguiFrame}), the {@link AguiBrackets} stream machine, and
27
- * the `cotal.*` `CUSTOM` table, which is empty in v1.
26
+ * ({@link isAguiFramePart} / {@link parseAguiFrame}), the {@link AguiBrackets} stream machine, the
27
+ * `cotal.*` `CUSTOM` table (empty in v1), the preview plane's {@link splitFrames}, and the egress
28
+ * policy that decides which events may leave for the event channel.
28
29
  *
29
- * Not here, and NAMED rather than stubbed, because a stub is a claim that the shape is known: the
30
- * channel derivation, the `max_payload` split, and the emitter that publishes. Each of those talks
31
- * to something outside this module, and each lands with the surface that calls it.
30
+ * Not here: the emitter that publishes, with the channel derivation and the durable source, WAL and
31
+ * subject frontier it drives. Those are in `agui-emitter.ts`, which imports this module and is never
32
+ * imported by it, so a change to the delivery machine cannot reach the vocabulary.
32
33
  *
33
- * **Nothing constructs a frame outside a smoke.** No connector emits, and this module publishes
34
- * nothing: every export below is a pure function of its arguments. That is a statement about the
35
- * tree, not a disclaimer — a reader deciding whether a change here can reach a customer needs it to
36
- * be accurate, so it is maintained rather than left to rot.
34
+ * **This module publishes nothing and does no I/O.** A reader deciding whether a change here can
35
+ * reach the wire on its own needs that to be accurate, so it is maintained rather than left to rot.
37
36
  */
38
37
  // The frame's wire identity lives in `@cotal-ai/core`: an adopted vocabulary is a standard concept,
39
38
  // and core must be able to RENDER a frame without depending on an extension. Re-exported here so an
@@ -42,10 +41,6 @@
42
41
  // `packages/core/src/agui-kind.ts`.
43
42
  export { AGUI_FRAME_KIND, AGUI_EVENT_TYPE, isAguiFramePart } from "@cotal-ai/core";
44
43
  import { AGUI_FRAME_KIND, AGUI_EVENT_TYPE, isAguiFramePart } from "@cotal-ai/core";
45
- import { randomUUID } from "node:crypto";
46
- import { isCasLoss, principalKey } from "@cotal-ai/core";
47
- import { dirname } from "node:path";
48
- import { eventChannelForSession } from "./launch.js";
49
44
  /**
50
45
  * The `cotal.*` `CUSTOM` event table — the second and ONLY other vehicle for Cotal-specific data.
51
46
  *
@@ -566,13 +561,13 @@ const TRUNCATABLE_FIELDS = [
566
561
  * surrogate pair in half and produce a lone surrogate, which is not well-formed UTF-16 — the exact
567
562
  * defect `fix(core)!: refuse names that are not well-formed UTF-16` landed on this branch for. A
568
563
  * splitter that reintroduced it here would emit a frame the wire layer is now obliged to refuse. */
569
- const takeCodePoints = (s, codePoints) => Array.from(s).slice(0, codePoints).join("");
564
+ export const takeCodePoints = (s, codePoints) => Array.from(s).slice(0, codePoints).join("");
570
565
  /**
571
566
  * Split `events` into as many frames as the wire requires, truncating only what physically cannot
572
567
  * cross it, and LABELLING every truncation.
573
568
  *
574
569
  * **THIS IS THE PREVIEW PLANE'S SPLITTER, AND IT HAS NO DURABLE-PLANE CALLER BY DESIGN.**
575
- * Read that as a boundary, not as an oversight: the durable emitter packs with {@link packUnits} at
570
+ * Read that as a boundary, not as an oversight: the durable emitter packs with `packUnits` at
576
571
  * SOURCE-RECORD boundaries and refuses an oversized unit, because one durable emit unit must be one
577
572
  * frame carrying a cursor that resumes after it, and a frame ending mid-record has no cursor it can
578
573
  * honestly store. This event-boundary split and its labelled truncation were specified before the
@@ -581,10 +576,11 @@ const takeCodePoints = (s, codePoints) => Array.from(s).slice(0, codePoints).joi
581
576
  * right answer and why this machinery is worth keeping.
582
577
  *
583
578
  * **Calling this from the durable emitter would be a silent-loss bug**, not a performance choice —
584
- * so if you are here looking for the packer, you want `packUnits`. And it is marked rather than
585
- * deleted for the reason one module over already demonstrated: `assertExpectationSemantics()` sat
586
- * with zero production callers looking exactly like live code, and unreachable code that looks live
587
- * is a hazard whichever direction the next reader resolves it in.
579
+ * so if you are here looking for the packer, you want `packUnits` in `agui-emitter.ts`. And it is
580
+ * marked rather than deleted for the reason one module over already demonstrated:
581
+ * `assertExpectationSemantics()` sat with zero production callers looking exactly like live code,
582
+ * and unreachable code that looks live is a hazard whichever direction the next reader resolves it
583
+ * in.
588
584
  *
589
585
  * **Say the uncomfortable thing:** this is a content truncation, which is one of the
590
586
  * sins `tr-` is being abolished for. The difference is not that we are gentler about it. `tr-` cut
@@ -699,132 +695,6 @@ function truncateToFit(event, seq, fits, limit, measure, build) {
699
695
  }
700
696
  return at(lo);
701
697
  }
702
- /**
703
- * A bracket violation that is OURS, not the writer's: the machine that tracks open runs and messages
704
- * was lost across a process restart.
705
- *
706
- * **This exists because two halts that both say "unbalanced" prove nothing about which produced
707
- * one.** The WAL persists `epoch`, `frontier` and the pending frame, and NOT the set of open
708
- * runs and messages, so a process that dies mid-run restarts with an empty {@link AguiBrackets},
709
- * resumes from `sourceCursor` at events whose `RUN_STARTED` was already published, and refuses the
710
- * first of them. Without this class the operator sees "nothing may be emitted outside an open run"
711
- * and files a bug against a writer that did nothing wrong.
712
- *
713
- * It is deliberately a SUBCLASS: every existing catch of {@link AguiVocabularyError} still catches
714
- * it, and only code that wants to tell the two apart has to know it exists.
715
- */
716
- export class AguiBracketStateLost extends AguiVocabularyError {
717
- cause;
718
- constructor(message, cause) {
719
- super(message);
720
- this.cause = cause;
721
- this.name = "AguiBracketStateLost";
722
- }
723
- }
724
- /**
725
- * The emitter has stopped and will not publish again without operator action.
726
- *
727
- * Halting is a SUCCESS of this design, not a failure of it: every halt below is a case where the
728
- * alternative is to report success for a message that was not stored, or to fold an ack for a body
729
- * we did not write. A halt is loud, bounded and recoverable by a human; the alternative is silent
730
- * and permanent.
731
- */
732
- export class AguiEmitterHalted extends Error {
733
- reason;
734
- constructor(reason, message) {
735
- super(message);
736
- this.reason = reason;
737
- this.name = "AguiEmitterHalted";
738
- }
739
- }
740
- /**
741
- * The id used only for SIZING, and it is the longest one `assertIdToken` admits.
742
- *
743
- * Sizing must never report a smaller number than publishing will produce, and the id is not known
744
- * when a frame is measured — it is minted per publish attempt. Measuring at the maximum admissible
745
- * length makes the measurement an UPPER BOUND over every id the emitter could mint, which costs a
746
- * few bytes of packing density and removes an entire class of near-ceiling defect. The alternative,
747
- * measuring with the id we intend to use, requires minting before packing and freezing an id for a
748
- * frame that may never be built.
749
- */
750
- const SIZING_ID = "S".repeat(64);
751
- /**
752
- * Likewise for the expectation, and this one CANNOT be known at pack time even in principle.
753
- *
754
- * `expectedLastSubjectSeq` for frame k+1 is the sequence the broker assigns frame k, and the stream
755
- * sequence advances with every message in the space, not only ours. Its decimal length is therefore
756
- * unknowable while packing. `MAX_SAFE_INTEGER` is the widest value the publish path will accept, so
757
- * measuring at it bounds every expectation the emitter can ever send.
758
- */
759
- const SIZING_EXPECTATION = Number.MAX_SAFE_INTEGER;
760
- /**
761
- * Pack units into frames, splitting ONLY at unit boundaries and never inside one.
762
- *
763
- * Deliberately NOT {@link splitFrames}, and the difference is the durable plane's one-unit-one-frame
764
- * rule. `splitFrames` splits at EVENT boundaries, which is the right answer for a frame considered
765
- * on its own, but a frame that ends mid-record has no cursor it can honestly store: the only value
766
- * available says the whole record was consumed, and folding that after a crash skips the rest of the
767
- * record's events with no `seq` gap for a consumer to notice.
768
- *
769
- * **So the event-boundary split and the one-unit-one-frame rule are in tension, and this resolves it
770
- * in the direction the durable plane requires: a single unit that does not fit FAILS LOUD rather
771
- * than being truncated at a frame boundary.** That leaves `splitFrames`'s truncation path with no
772
- * caller on the durable plane, which is reported as a design conflict rather than decided here.
773
- *
774
- * @throws {AguiVocabularyError} when one unit cannot fit in a frame alone.
775
- */
776
- export function packUnits(opts) {
777
- const { threadId, epoch, measure, limit } = opts;
778
- if (!Number.isSafeInteger(limit) || limit <= 0)
779
- throw new AguiVocabularyError(`pack limit must be a positive safe integer, got ${JSON.stringify(limit)}`);
780
- const out = [];
781
- let seq = opts.firstSeq;
782
- let batch = [];
783
- let batchRun;
784
- let batchCursor;
785
- const flush = () => {
786
- if (batch.length === 0)
787
- return;
788
- out.push({ frame: aguiFrame({ threadId, runId: batchRun, epoch, seq, events: batch }), cursor: batchCursor });
789
- seq += 1;
790
- batch = [];
791
- batchRun = undefined;
792
- batchCursor = undefined;
793
- };
794
- for (const unit of opts.units) {
795
- if (unit.events.length === 0)
796
- throw new AguiVocabularyError("packUnits was handed an empty unit; a record that maps to nothing advances the cursor and never becomes a frame");
797
- // A frame names ONE run. A unit from a different run cannot join the open batch even if it
798
- // would fit, so the run change is a flush and not a size decision.
799
- if (batchRun !== undefined && unit.runId !== batchRun)
800
- flush();
801
- const candidate = [...batch, ...unit.events];
802
- const fits = measure(aguiFrame({ threadId, runId: batchRun ?? unit.runId, epoch, seq, events: candidate })) <= limit;
803
- if (fits) {
804
- batch = candidate;
805
- batchRun = batchRun ?? unit.runId;
806
- batchCursor = unit.cursor;
807
- continue;
808
- }
809
- // It did not fit WITH the open batch. Flush and try it alone before concluding anything about
810
- // the unit itself: an ordinary unit that happens to arrive behind a nearly-full frame is not an
811
- // oversized unit, and treating it as one would halt the emitter on a packing accident.
812
- flush();
813
- const alone = aguiFrame({ threadId, runId: unit.runId, epoch, seq, events: unit.events });
814
- const aloneBytes = measure(alone);
815
- if (aloneBytes > limit)
816
- throw new AguiVocabularyError(`a single source observation does not fit in one frame (${aloneBytes} > ${limit} bytes, ` +
817
- `${unit.events.length} event(s), run ${unit.runId}). One source observation is one frame, ` +
818
- `and that rule requires this to fail loud rather ` +
819
- `than be truncated at a frame boundary: a frame that ends mid-record has no cursor it can ` +
820
- `honestly store, and a dropped boundary with no gap marker is worse than a halt.`);
821
- batch = [...unit.events];
822
- batchRun = unit.runId;
823
- batchCursor = unit.cursor;
824
- }
825
- flush();
826
- return out;
827
- }
828
698
  /**
829
699
  * Events that must never reach `events.<owner>.<actor>`.
830
700
  *
@@ -1064,566 +934,4 @@ export function frozenBodyEgressVerdict(body) {
1064
934
  }
1065
935
  return unreadable ? "unreadable" : "clean";
1066
936
  }
1067
- /**
1068
- * The notice a bounded `RUN_ERROR` carries so a reader cannot mistake a shortened or omitted
1069
- * upstream detail for the original. The close path is the one place this string is composed.
1070
- */
1071
- const RUN_ERROR_DETAIL_BOUND_NOTICE = "original detail omitted or shortened because it exceeded the frame bound";
1072
- /**
1073
- * Rebuild a `RUN_ERROR` so the closing frame fits the live payload ceiling.
1074
- *
1075
- * **This exists because the close is a terminal a reader waits on, and the message is untrusted
1076
- * upstream free text.** `packUnits` is right to refuse an oversized source observation — a unit
1077
- * that does not fit has no honest cursor. A close unit is not a source observation: we author it,
1078
- * it consumes no record, and refusing it leaves the run with no terminal, no pending WAL recovery,
1079
- * and a dead holder. So the close rebuilds the one event until the SAME `measure` `packUnits` will
1080
- * use says it fits, and says in the message that the original detail was omitted or shortened. A
1081
- * short message that already fits is returned unchanged. Since #1431 the close passes only
1082
- * {@link RUN_ERROR_EGRESS_MESSAGE}, which is shorter than the notice, so this either returns it
1083
- * unchanged or throws.
1084
- *
1085
- * It does not live in `packUnits` and it does not call {@link splitFrames}. Those are a different
1086
- * contract (source-record packing, and the preview plane). Putting the bound on this close is the
1087
- * one place every connector already goes through.
1088
- */
1089
- function boundRunErrorForFrame(opts) {
1090
- const build = (message) => runError({
1091
- message,
1092
- timestamp: opts.timestamp,
1093
- ...(opts.code ? { code: opts.code } : {}),
1094
- ...(opts.cotal ? { cotal: opts.cotal } : {}),
1095
- });
1096
- const frameOf = (event) => aguiFrame({
1097
- threadId: opts.threadId,
1098
- runId: opts.runId,
1099
- epoch: opts.epoch,
1100
- seq: opts.seq,
1101
- events: [event],
1102
- });
1103
- const original = build(opts.message);
1104
- if (opts.measure(frameOf(original)) <= opts.limit)
1105
- return original;
1106
- const labelled = (kept) => kept.length === 0 ? RUN_ERROR_DETAIL_BOUND_NOTICE : `${RUN_ERROR_DETAIL_BOUND_NOTICE}: ${kept}`;
1107
- const at = (codePoints) => build(labelled(takeCodePoints(opts.message, codePoints)));
1108
- const emptied = at(0);
1109
- if (opts.measure(frameOf(emptied)) > opts.limit)
1110
- throw new AguiVocabularyError(`a RUN_ERROR close does not fit in ${opts.limit} bytes even with the failure detail emptied ` +
1111
- `and replaced by a bound notice — the envelope, the code and the headers alone are ` +
1112
- `${opts.measure(frameOf(emptied))} bytes. No bound on the detail can help, so this fails ` +
1113
- `loudly rather than leaving the run without a terminal.`);
1114
- // Binary-search the largest well-formed prefix that still fits, re-measuring the whole frame
1115
- // each step: JSON escaping and UTF-8 length are nonlinear, so cutting by the overage is wrong.
1116
- let lo = 0;
1117
- let hi = Array.from(opts.message).length + 1;
1118
- while (hi - lo > 1) {
1119
- const mid = Math.floor((lo + hi) / 2);
1120
- if (opts.measure(frameOf(at(mid))) <= opts.limit)
1121
- lo = mid;
1122
- else
1123
- hi = mid;
1124
- }
1125
- return at(lo);
1126
- }
1127
- /**
1128
- * The event emitter: one per principal, one thread at a time.
1129
- *
1130
- * **BRACKET STATE SURVIVES A RESTART, AND THIS PARAGRAPH USED TO SAY THE OPPOSITE.** It described a
1131
- * declared gap — an emitter coming back with an empty machine, resuming at events whose
1132
- * `RUN_STARTED` had already been published, and refusing the first of them — long after the WAL
1133
- * started persisting the machine. The words were true when they were written and stayed on the page
1134
- * through the change that falsified them, which is the failure mode a class header is worst at
1135
- * showing: it is the first thing a cutover author reads about recovery, and it was telling them to
1136
- * expect a halt the code no longer produces.
1137
- *
1138
- * What actually happens: {@link AguiBrackets} is a property of the WRITER'S STREAM across frames, so
1139
- * the WAL freezes the machine's state WITH each pending frame and promotes it on fold. A restart
1140
- * therefore reopens knowing exactly which run, messages and tool calls were open at the last FOLDED
1141
- * position, and {@link AguiBrackets.restore} continues from there rather than from empty.
1142
- *
1143
- * **The lost-state path still exists, and it is now the narrow case it should always have been:** a
1144
- * document that CANNOT SAY what was open. That is a WAL migrated from v1, which recorded no bracket
1145
- * state at all, and it loads as `null` rather than as an empty machine precisely so the difference
1146
- * stays visible. Only there does the emitter start empty, resume into an already-open run, and
1147
- * refuse the first event with {@link AguiBracketStateLost} — a halt rather than a loss, which is the
1148
- * safe direction, and diagnosed by name rather than surfacing as an anonymous protocol violation.
1149
- */
1150
- export class AguiEmitter {
1151
- ep;
1152
- wal;
1153
- source;
1154
- map;
1155
- channel;
1156
- threadId;
1157
- /**
1158
- * The bracket machine AT THE FOLDED POSITION — deliberately not "wherever validation got to".
1159
- *
1160
- * It advances one frame at a time, immediately before that frame's `beginSend`, so the state
1161
- * frozen with a pending frame is the state that belongs to it. A machine advanced by the whole
1162
- * batch up front would freeze a state describing events that had not been sent.
1163
- */
1164
- brackets;
1165
- halted;
1166
- /** True once THIS process has fed an event through the bracket machine. It is the half of the
1167
- * restart diagnosis that keeps a genuine mid-stream violation from being blamed on a restart. */
1168
- fedAnyEvent = false;
1169
- constructor(ep, wal, source, map,
1170
- /** Derived from the endpoint's OWN principal, never from a config name or the launch env. */
1171
- channel, threadId) {
1172
- this.ep = ep;
1173
- this.wal = wal;
1174
- this.source = source;
1175
- this.map = map;
1176
- this.channel = channel;
1177
- this.threadId = threadId;
1178
- // RESTORED FROM THE WAL, which is the whole point of the v2 migration: a process that died
1179
- // mid-run comes back knowing which runs and messages are open, instead of refusing the first
1180
- // event it re-reads. `null` means the document cannot say (migrated from v1) — an empty machine
1181
- // is the honest starting point there, and `diagnoseBracket` is what keeps the resulting refusal
1182
- // from being blamed on the writer.
1183
- this.brackets = wal.brackets ? AguiBrackets.restore(wal.brackets) : new AguiBrackets();
1184
- }
1185
- /**
1186
- * Start an emitter: resolve the channel, run the single-replica preflight, and settle any pending
1187
- * frame.
1188
- *
1189
- * **THIS IS THAT PREFLIGHT'S PRODUCTION CALL SITE, AND UNTIL THIS FUNCTION EXISTED THERE WAS
1190
- * NONE.** `CotalEndpoint.assertExpectationSemantics()` had zero production callers: it was a
1191
- * check that shipped, was covered by its own suite, and never ran outside one. That is why it is
1192
- * called HERE, before recovery and therefore before any publish — a serialized append on an
1193
- * unverified stream is the exact case it exists to prevent, and doing it after recovery would
1194
- * leave the one publish that matters most, the re-publish of a frozen frame, outside the guard.
1195
- */
1196
- static async start(opts) {
1197
- const { endpoint, wal } = opts;
1198
- const channel = eventChannelForSession(endpoint);
1199
- // The WAL must be THIS principal's. `EventWal.open` refuses a document whose stored principal
1200
- // disagrees with what it was asked for, but that protects the file against being mistaken for
1201
- // another; it cannot notice an emitter handed the wrong WAL object. Publishing under one
1202
- // principal's identity while recovering another's frozen `E` is a fabricated frontier.
1203
- const live = principalKey(endpoint.principal.owner, endpoint.principal.actor).key;
1204
- if (wal.principal !== live)
1205
- throw new Error(`event WAL belongs to principal ${wal.principal}, but this endpoint is ${live} — refusing to ` +
1206
- `publish under one identity from another's write-ahead log`);
1207
- // THE SINGLE-REPLICA PREFLIGHT: before recovery, before any publish.
1208
- await endpoint.assertExpectationSemantics();
1209
- // REQUIRED AT RUNTIME, NOT ONLY IN THE TYPE. Smoke files in this repo are not typechecked, so a
1210
- // caller that omits this would bind `undefined`, fall back to the per-thread number, and pass
1211
- // every existing cell while shipping the exact defect this parameter exists to remove. A type
1212
- // that only the compiler enforces is not a guard for the callers the compiler never sees.
1213
- if (!opts.subjectFrontier || typeof opts.subjectFrontier.advance !== "function")
1214
- throw new Error(`event emitter for ${channel}: a subject frontier is required — the subject is shared by every ` +
1215
- `thread of this principal, so the publish expectation cannot come from one thread's log`);
1216
- // BOUND BEFORE RECOVERY, and the order matters for the same reason the preflight's does:
1217
- // recovery can republish a frozen frame, and a WAL whose expectation still came from its own
1218
- // thread would republish against the wrong tip.
1219
- await wal.bindSubjectFrontier(opts.subjectFrontier);
1220
- const em = new AguiEmitter(endpoint, wal, opts.source, opts.map, channel, wal.threadId);
1221
- await em.recover();
1222
- return em;
1223
- }
1224
- /** True once the emitter has stopped for good. */
1225
- get stopped() {
1226
- return this.halted !== undefined;
1227
- }
1228
- /** The run {@link closeRun} would close now, or `undefined` at a stopping point. */
1229
- get openRunId() {
1230
- return this.brackets.runId;
1231
- }
1232
- /**
1233
- * Boot recovery, branching on the WAL's tag.
1234
- *
1235
- * `acked` NEVER republishes: the frame landed and we know it, so the only remaining work is to
1236
- * fold. `sent_unacked` is the genuinely uncertain case and republishes with the SAME frozen `id`
1237
- * and `E` — never the current tip, because re-deriving either is what turns an uncertain publish
1238
- * into a second, different message.
1239
- */
1240
- async recover() {
1241
- const p = this.wal.pending;
1242
- if (!p)
1243
- return;
1244
- if (p.state === "acked") {
1245
- await this.wal.fold();
1246
- this.brackets = AguiBrackets.restore(p.brackets);
1247
- return;
1248
- }
1249
- // The retried frame was accepted by a PREVIOUS process and is not fed through this instance's
1250
- // machine again. Folding promotes its bracket snapshot on disk; promote the same snapshot here
1251
- // or this emitter continues from the pre-pending state and rejects the next legal event.
1252
- await this.attempt({ id: p.id, E: p.E, body: p.body, retry: true });
1253
- this.brackets = AguiBrackets.restore(p.brackets);
1254
- }
1255
- /**
1256
- * Read forward, map, pack, and publish. Returns what it did, so a caller can distinguish "nothing
1257
- * to do" from "did work" without inspecting the WAL.
1258
- */
1259
- async pump() {
1260
- if (this.halted)
1261
- throw this.halted;
1262
- if (this.wal.pending)
1263
- throw new Error(`event emitter for ${this.channel}: a frame is still pending; recovery must settle it before a new read`);
1264
- const read = await this.source.read(this.wal.frontier.sourceCursor);
1265
- // Units the mapper dropped are not errors and not frames. Their cursor folds FORWARD into the
1266
- // preceding unit, so consuming that frame consumes them too and they are never re-read. A drop
1267
- // stays apart from a mapper error, which reaches the caller with the cursor unmoved.
1268
- const units = [];
1269
- for (const rec of read.records) {
1270
- const mapped = this.map(rec.value);
1271
- if (mapped === null || mapped.events.length === 0) {
1272
- const last = units[units.length - 1];
1273
- if (last)
1274
- last.cursor = rec.cursor;
1275
- continue;
1276
- }
1277
- // WRITE-PATH POLICY, before packing and therefore before beginSend. Disk and wire then agree.
1278
- // A record that mapped only to forbidden kinds becomes a drop: its cursor folds forward like
1279
- // any other drop, and packUnits never sees an empty unit.
1280
- const events = applyAguiEgressPolicy(mapped.events);
1281
- if (events.length === 0) {
1282
- const last = units[units.length - 1];
1283
- if (last)
1284
- last.cursor = rec.cursor;
1285
- continue;
1286
- }
1287
- units.push({ runId: mapped.runId, events, cursor: rec.cursor });
1288
- }
1289
- // A bounded range that mapped to nothing advances the cursor atomically and ALONE.
1290
- //
1291
- // THIS IS THE COMMON PATH, NOT AN EDGE CASE, and the number is here so nobody reads it as one.
1292
- // Measured on a real Claude session of 5938 records: only 2694 (45%) carry a `message` at all —
1293
- // the rest are attachments, queue operations, mode changes, prompt markers and system entries
1294
- // the mapping deliberately drops. So the MAJORITY of a real session maps to nothing. An emitter
1295
- // advanced the cursor only through an acked frame would re-read the same 55% forever, and no
1296
- // fixture would ever show it, because a fixture author writes records that mean something.
1297
- //
1298
- // It must also advance on an ADOPT, which reads zero records by design: without that the position
1299
- // is never persisted and the next read adopts a LATER end, silently skipping everything appended
1300
- // in between.
1301
- if (units.length === 0) {
1302
- if (read.cursor !== this.wal.frontier.sourceCursor)
1303
- await this.wal.advanceCursorOnly(read.cursor);
1304
- return { frames: 0, events: 0 };
1305
- }
1306
- // Validate the WHOLE batch before publishing any of it. A vocabulary violation discovered
1307
- // halfway through would leave a valid prefix on the wire and the rest refused, and the refusal
1308
- // is supposed to mean "this stream never carried that", not "it carried some of it".
1309
- // Validated on a CLONE, so the machine that is in step with the disk does not advance for a
1310
- // batch that may never be sent. The clone starts from the folded state, so it sees exactly what
1311
- // the real machine will see, in the same order.
1312
- const probe = this.brackets.clone();
1313
- for (const u of units)
1314
- for (const e of u.events) {
1315
- try {
1316
- probe.accept(e);
1317
- }
1318
- catch (err) {
1319
- throw this.diagnoseBracket(err);
1320
- }
1321
- }
1322
- // Set only after the WHOLE batch validated. Setting it per event would make a batch that failed
1323
- // on its FIRST event count as "this process has fed something", which is the precise input that
1324
- // turns the restart diagnosis off.
1325
- this.fedAnyEvent = true;
1326
- const frames = packUnits({
1327
- threadId: this.threadId,
1328
- epoch: this.wal.epoch,
1329
- firstSeq: this.wal.frontier.seq + 1,
1330
- units,
1331
- measure: (f) => this.measure(f),
1332
- limit: this.ep.maxPayload,
1333
- });
1334
- let events = 0;
1335
- for (const { frame, cursor } of frames) {
1336
- await this.publish(frame, cursor);
1337
- events += frame.events.length;
1338
- }
1339
- // Records the mapper dropped AFTER the last unit have no frame to ride on, so their cursor is
1340
- // advanced on its own: the same cursor-only rule, applied to the tail of the batch.
1341
- if (read.cursor !== this.wal.frontier.sourceCursor)
1342
- await this.wal.advanceCursorOnly(read.cursor);
1343
- return { frames: frames.length, events };
1344
- }
1345
- /**
1346
- * Close the run this stream currently has open, at a boundary the RECORD STREAM CANNOT SEE.
1347
- *
1348
- * **This exists because the two halves of the mapping were specified against different inputs.**
1349
- * The plan sources `RUN_FINISHED` from a harness lifecycle hook, and the durable plane reads a
1350
- * FILE: a hook fires in another process and writes no record, so a hook-sourced terminal has no
1351
- * vehicle into a record-sourced stream. Deriving the terminal from records instead is possible but
1352
- * lies about time in two ways that matter to a live view: the finish lands only when the NEXT turn
1353
- * starts, so a finished agent renders as still running, and the last run of a session never closes
1354
- * at all, because there is no later record to close it on. This is that vehicle.
1355
- *
1356
- * It is a FRAME LIKE ANY OTHER: same epoch, same `seq` line, same write-ahead discipline, same
1357
- * halt rules. The single thing that differs is the cursor, which is republished UNCHANGED, because
1358
- * this frame consumes no source record. A frame that advanced the cursor here would mark records
1359
- * consumed that were never mapped.
1360
- *
1361
- * Idempotent by construction rather than by a flag: the bracket machine is the only state it
1362
- * reads, so once the run is closed there is nothing open to close and it answers `null`. That also
1363
- * makes it safe on a stream whose run was opened by a PREVIOUS process, since the machine is
1364
- * restored from the WAL.
1365
- *
1366
- * **AN `error` CLOSES THE SAME RUN WITH `RUN_ERROR` INSTEAD, and it is one method rather than two
1367
- * ON PURPOSE.** `RUN_ERROR` closes a run on its own, so a run that emitted one must never also
1368
- * emit a `RUN_FINISHED`. With a second method that invariant would be a rule someone has to
1369
- * remember; with one method and one branch it is a property of the shape: exactly one terminal is
1370
- * built, and the bracket machine has closed the run by the time anything could ask for another, so
1371
- * a following close answers `null` like any other close on a settled stream. Which harness signals
1372
- * mean a turn FAILED is a connector's decision and is stated at each connector's own mapping site;
1373
- * this file only carries the answer to the wire.
1374
- *
1375
- * **`error.message` AND `error.code` NEVER REACH THE WIRE.** Both are upstream values, so the close
1376
- * publishes {@link RUN_ERROR_EGRESS_MESSAGE} with no code, the same shape the write path's
1377
- * {@link egressRunError} produces (#1431). The frame bound still runs after that, so a close whose
1378
- * envelope cannot fit fails loud rather than leaving the run without a terminal.
1379
- *
1380
- * @returns the run that was closed, or `null` when the stream was already at a stopping point.
1381
- */
1382
- async closeRun(o) {
1383
- if (this.halted)
1384
- throw this.halted;
1385
- if (this.wal.pending)
1386
- throw new Error(`event emitter for ${this.channel}: a frame is still pending; recovery must settle it before a run can be closed`);
1387
- const runId = this.brackets.runId;
1388
- if (runId === undefined)
1389
- return null;
1390
- const cursor = this.wal.frontier.sourceCursor;
1391
- if (cursor === undefined)
1392
- throw new Error(`event emitter for ${this.channel}: run "${runId}" is open on a frontier that carries no source ` +
1393
- `cursor. A run can only be open because a frame published it, and a frame that published ` +
1394
- `cannot leave the cursor unset, so this WAL disagrees with itself. Refusing to invent a ` +
1395
- `cursor for the closing frame.`);
1396
- // `RUN_ERROR` carries no `runId` and no `threadId` of its own — its schema is `message` plus an
1397
- // optional `code` — so the run it closes is named by the frame's unit below, not by the event.
1398
- // The bound is measured with the same function and ceiling `packUnits` will use, at the `seq`
1399
- // the closing frame will actually carry.
1400
- const event = o.error
1401
- ? boundRunErrorForFrame({
1402
- message: RUN_ERROR_EGRESS_MESSAGE,
1403
- timestamp: o.timestamp,
1404
- ...(o.cotal ? { cotal: o.cotal } : {}),
1405
- threadId: this.threadId,
1406
- runId,
1407
- epoch: this.wal.epoch,
1408
- seq: this.wal.frontier.seq + 1,
1409
- measure: (f) => this.measure(f),
1410
- limit: this.ep.maxPayload,
1411
- })
1412
- : runFinished({
1413
- threadId: this.threadId,
1414
- runId,
1415
- timestamp: o.timestamp,
1416
- ...(o.cotal ? { cotal: o.cotal } : {}),
1417
- });
1418
- // Validated on a clone first, exactly as a mapped batch is. The refusal that matters here is a
1419
- // message or tool call still open under this run: either terminal while something it opened is
1420
- // unclosed is a protocol violation, and it must surface as one rather than be published.
1421
- const probe = this.brackets.clone();
1422
- try {
1423
- probe.accept(event);
1424
- }
1425
- catch (err) {
1426
- throw this.diagnoseBracket(err);
1427
- }
1428
- this.fedAnyEvent = true;
1429
- const frames = packUnits({
1430
- threadId: this.threadId,
1431
- epoch: this.wal.epoch,
1432
- firstSeq: this.wal.frontier.seq + 1,
1433
- units: [{ runId, events: [event], cursor }],
1434
- measure: (f) => this.measure(f),
1435
- limit: this.ep.maxPayload,
1436
- });
1437
- for (const { frame, cursor: c } of frames)
1438
- await this.publish(frame, c);
1439
- return runId;
1440
- }
1441
- /** Measure a candidate frame EXACTLY as the wire will, at an upper bound over id and expectation. */
1442
- measure(frame) {
1443
- return this.ep.encodedSize({
1444
- channel: this.channel,
1445
- parts: [frame],
1446
- id: SIZING_ID,
1447
- expectedLastSubjectSeq: SIZING_EXPECTATION,
1448
- });
1449
- }
1450
- /** Transition 1 then the first network attempt. */
1451
- async publish(frame, cursor) {
1452
- // Advance the real machine by exactly this frame. It cannot throw: the identical sequence was
1453
- // already accepted by a clone starting from this same state, in this same order. It is not
1454
- // wrapped in a diagnosis for that reason — a throw here would be a bug in this file, not a
1455
- // stream problem, and dressing it as one would hide it.
1456
- for (const e of frame.events)
1457
- this.brackets.accept(e);
1458
- const brackets = this.brackets.snapshot();
1459
- const id = randomUUID();
1460
- // THE SUBJECT'S TIP, NOT THIS THREAD'S LAST ACK. The two were the same number until a second
1461
- // session of the same principal existed, and then they were not: the subject is per principal
1462
- // and the log is per thread, so a new thread's own `lastSubjectSeq` is 0 on a subject its
1463
- // predecessor already filled.
1464
- const E = this.wal.expectedTip;
1465
- const body = [frame];
1466
- // Durable BEFORE the wire. The order is the whole state machine: a crash between this line and
1467
- // the next is recoverable precisely because the id and `E` are already frozen on disk.
1468
- await this.wal.beginSend({ id, E, seq: frame.seq, sourceCursor: cursor, body, brackets });
1469
- await this.attempt({ id, E, body, retry: false });
1470
- }
1471
- /**
1472
- * One publish attempt — first or retry — with the FROZEN id and the FROZEN `E`. Never the tip.
1473
- *
1474
- * The three outcomes are not symmetric and the asymmetry is the design:
1475
- * - `!duplicate` → transition 2 then 3. Success becomes durable before the frontier moves.
1476
- * - `duplicate` → HALT. On a first attempt it means a body WE DID NOT WRITE holds our id, and
1477
- * folding its `ackSeq` would advance the frontier and the source cursor past events that were
1478
- * never published. On a retry it cannot happen on a single-replica stream at all, because such a
1479
- * stream evaluates the expectation before the dedup cache, so observing it proves the stream is
1480
- * not single-replica. Both are
1481
- * fail-loud, and neither is a case where guessing is better than stopping.
1482
- * - CAS loss → HALT. Someone else moved the tip on a subject only this principal may write, or
1483
- * the subject was purged. Uncertainty plus a moved tip is exactly what must not be guessed at.
1484
- *
1485
- * A NETWORK error is deliberately none of these: it leaves `pending` as `sent_unacked`, which is
1486
- * the state that means "we do not know", and the next boot retries the same frozen frame.
1487
- */
1488
- async attempt(o) {
1489
- const egress = frozenBodyEgressVerdict(o.body);
1490
- if (egress === "forbidden-kind") {
1491
- throw this.halt("egress-policy", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
1492
- `frame whose body carries TOOL_CALL_ARGS or TOOL_CALL_RESULT onto ${this.channel}. ` +
1493
- `That channel has a different read ACL from the one a mesh read tool ran on, and those ` +
1494
- `kinds republish tool inputs and outputs. The body is not rewritten: the WAL froze it at ` +
1495
- `beginSend, a retry must republish those bytes or not at all, and mutating them between ` +
1496
- `disk and wire would break the recovery machine. An upgrade across a pending pre-fix ` +
1497
- `frame therefore HALTS rather than leaks. Clear the pending frame only as an explicit ` +
1498
- `abandonment of this epoch.`);
1499
- }
1500
- if (egress === "unreadable") {
1501
- throw this.halt("egress-unreadable", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
1502
- `frame whose event list this policy cannot read onto ${this.channel}. A frame-shaped body ` +
1503
- `whose \`events\` is absent, or is not an array, cannot be checked for TOOL_CALL_ARGS or ` +
1504
- `TOOL_CALL_RESULT, and a body whose \`events\` is a bare string carries its bytes exactly ` +
1505
- `where the check would have looked. The boundary is the wire and not what a renderer ` +
1506
- `folds, so an uninspectable body is withheld rather than published opaque onto a channel ` +
1507
- `with a different read ACL. \`aguiFrame\` enforces a non-empty events ARRAY at ` +
1508
- `construction, so no frame this version writes can land here; one that does was frozen by ` +
1509
- `something else. Clear the pending frame only as an explicit abandonment of this epoch.`);
1510
- }
1511
- if (egress === "run-error-content") {
1512
- throw this.halt("egress-run-error", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
1513
- `frame whose RUN_ERROR carries upstream text onto ${this.channel}. Since #1431 a published ` +
1514
- `RUN_ERROR has the fixed message "${RUN_ERROR_EGRESS_MESSAGE}" and no code or rawEvent, because ` +
1515
- `the upstream error text can echo a prompt, a peer message or tool output and that channel ` +
1516
- `has a different read ACL. The body is not rewritten: the WAL froze it at beginSend, so an ` +
1517
- `upgrade across a pending pre-fix frame HALTS rather than leaks. Clear the pending frame ` +
1518
- `only as an explicit abandonment of this epoch.`);
1519
- }
1520
- if (egress === "extra-property") {
1521
- // Re-scan to name the path. This is the error path; the cost is negligible.
1522
- let path = "(unknown)";
1523
- for (const part of o.body) {
1524
- if (!isAguiFramePart(part))
1525
- continue;
1526
- const p = extraPropertyPath(part);
1527
- if (p !== undefined) {
1528
- path = p;
1529
- break;
1530
- }
1531
- }
1532
- throw this.halt("egress-extra-property", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
1533
- `frame whose body carries an unknown property at \`${path}\` onto ${this.channel}. ` +
1534
- `The egress fence validates the whole envelope against a closed schema: known top-level ` +
1535
- `frame keys (kind, protocol, threadId, runId, epoch, seq, events) and known per-event ` +
1536
- `keys per type. A property outside that schema could carry tool output or other content ` +
1537
- `that bypasses the event-kind check. The body is not rewritten: the WAL froze it at ` +
1538
- `beginSend, and mutating it between disk and wire would break the recovery machine. ` +
1539
- `Clear the pending frame only as an explicit abandonment of this epoch.`);
1540
- }
1541
- let ack;
1542
- try {
1543
- ({ ack } = await this.ep.multicastExpecting({
1544
- channel: this.channel,
1545
- parts: o.body,
1546
- id: o.id,
1547
- expectedLastSubjectSeq: o.E,
1548
- }));
1549
- }
1550
- catch (e) {
1551
- if (isCasLoss(e))
1552
- throw this.halt("cas-loss", `event emitter for ${this.channel}: the subject tip is no longer ${o.E} (${e.message}). ` +
1553
- `The broker ACL confines this subject to one principal, so the tip moved for one of: a ` +
1554
- `CONCURRENT emitter under this same principal. The per-principal lock refuses a second ` +
1555
- `one, but the lock FILE lives under a workspace root, so an emitter started against a ` +
1556
- `DIFFERENT root, or by a path that never takes the lock, meets no lock at all. Another ` +
1557
- `host and a stale pid do not get past it; they refuse the start instead, loudly; a ` +
1558
- `subject frontier record that disagrees with the stream, ` +
1559
- `which is what an interrupted upgrade or a restored backup leaves behind; a RESTORED ` +
1560
- `stream; or a FILTERED PURGE, which returns the tip to 0 for every thread on the channel. ` +
1561
- `One more cause is not a second writer at all: this log's OWN last ack. The shared record ` +
1562
- `advances before the log records the ack, so a crash between those two writes leaves the ` +
1563
- `record ahead of the frozen expectation this frame carries, and the retry publishes a ` +
1564
- `sequence the subject has already passed. On disk it reads as a pending frame in state ` +
1565
- `sent_unacked whose E is BEHIND the record's tip, which a restored record can also look ` +
1566
- `like, so it narrows the search rather than ending it. ` +
1567
- `None of these is resolvable by re-reading the tip, which agent credentials cannot read in ` +
1568
- `any case. Clearing it is an explicit abandonment of epoch, seq, E, cursor and the shared ` +
1569
- `subject record together, and it is VALID ONLY ONCE THE SUBJECT IS ACTUALLY EMPTY, which ` +
1570
- `of the causes above is true of the FILTERED PURGE alone. On any other cause the tip is ` +
1571
- `still where it is, so removing this state does not clear the halt: the next session ` +
1572
- `opens virgin, expects 0, halts on the same tip, and the sibling logs a tip could have ` +
1573
- `been rebuilt from are gone. Purge the channel first, or find the second writer, or match the ` +
1574
- `signature above and stop looking for one. Once ` +
1575
- `the subject really is back to 0, no command performs the abandonment, so by hand it ` +
1576
- `means removing ${dirname(dirname(this.wal.path))} whole, and removing less than that ` +
1577
- `leaves a mixed state the next start refuses.`);
1578
- throw e;
1579
- }
1580
- if (ack.duplicate)
1581
- throw this.halt("duplicate-ack", `event emitter for ${this.channel}: the broker answered ${o.retry ? "a RETRY" : "a FIRST attempt"} ` +
1582
- `for id ${o.id} with duplicate:true. ` +
1583
- (o.retry
1584
- ? `Under the SINGLE-REPLICA RETRY RULE this cannot happen on an R1 stream, which ` +
1585
- `evaluates the subject expectation ` +
1586
- `before the dedup cache — so either the stream is not R1 or a foreign body holds our ` +
1587
- `stream-wide id. `
1588
- : `We have never published this id, so a body we did not write holds it. `) +
1589
- `Folding this ack would advance the frontier and the source cursor past events that were ` +
1590
- `never published: silent loss of real events. The frontier and cursor are unchanged.`);
1591
- await this.wal.recordAck(ack.seq);
1592
- await this.wal.fold();
1593
- }
1594
- /**
1595
- * Decide whether a bracket refusal is the WRITER's fault or OURS, and say which.
1596
- *
1597
- * Ours iff ALL THREE hold, and each is load-bearing:
1598
- * - this process has fed NO event through the machine yet, so the machine cannot have been put
1599
- * into a bad state by anything we did in this run; and
1600
- * - the frontier is non-virgin, so frames — and therefore possibly an open `RUN_STARTED` — were
1601
- * published by a PREVIOUS process; and
1602
- * - the WAL cannot say what was open. Since v2 the machine is PERSISTED, so an ordinary restart
1603
- * restores it and never reaches here at all; `null` means the document was migrated from v1 and
1604
- * genuinely never recorded the state. Without this condition the diagnosis would survive as a
1605
- * permanent excuse for a case the migration fixed.
1606
- *
1607
- * Drop the first condition and a genuine mid-stream violation by the writer gets blamed on a
1608
- * restart that happened an hour ago. Drop the second and a violation on a virgin thread, where
1609
- * nothing was ever published and nothing could have been lost, gets blamed on a restart that never
1610
- * happened. Each condition alone produces a confident, wrong diagnosis — which is worse than the
1611
- * undiagnosed error it replaced, because a named cause stops the search.
1612
- */
1613
- diagnoseBracket(err) {
1614
- if (this.fedAnyEvent || this.wal.frontier.seq === 0 || this.wal.brackets !== null)
1615
- return err;
1616
- return new AguiBracketStateLost(`event emitter for ${this.channel}: bracket state was LOST ACROSS A RESTART — this is not a ` +
1617
- `protocol violation by the writer. This process has emitted nothing yet, but the WAL says ` +
1618
- `frame ${this.wal.frontier.seq} already went out, and the document records NO bracket state ` +
1619
- `(it was migrated from v1, which never stored one), so any run or message the previous ` +
1620
- `process left open is invisible to this one. Resuming from the source cursor therefore lands ` +
1621
- `mid-run and the first event is refused. A WAL written by this build persists the machine and ` +
1622
- `does not reach this path. The underlying refusal was: ${err.message}`, err);
1623
- }
1624
- halt(reason, message) {
1625
- this.halted = new AguiEmitterHalted(reason, message);
1626
- return this.halted;
1627
- }
1628
- }
1629
937
  //# sourceMappingURL=agui.js.map