@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81

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.
Files changed (111) hide show
  1. package/README.md +84 -26
  2. package/dist/browser/client.d.ts +2 -0
  3. package/dist/browser/client.js +1 -0
  4. package/dist/browser/index.d.ts +520 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +2 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
  9. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
  10. package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
  11. package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +766 -0
  14. package/dist/node/index.d.cts.map +1 -0
  15. package/dist/node/index.d.ts +765 -27
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -0
  20. package/dist/node/node.d.cts +109 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +109 -0
  23. package/dist/node/node.d.ts.map +1 -0
  24. package/dist/node/node.js +2 -0
  25. package/dist/node/node.js.map +1 -0
  26. package/package.json +68 -47
  27. package/src/__tests__/access-token-verifier.test.ts +672 -0
  28. package/src/__tests__/admission.test.ts +130 -0
  29. package/src/__tests__/channel-frames.test.ts +214 -0
  30. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  31. package/src/__tests__/handoff-chunking.test.ts +215 -0
  32. package/src/__tests__/handoff-manifest.test.ts +52 -0
  33. package/src/__tests__/message-decoders.test.ts +249 -0
  34. package/src/__tests__/outbound-delivery.test.ts +558 -0
  35. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  36. package/src/__tests__/personal-usage-report.test.ts +193 -0
  37. package/src/__tests__/resume-buffer.test.ts +93 -0
  38. package/src/__tests__/session-event-delivery.test.ts +101 -0
  39. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  40. package/src/__tests__/session-message-handler.test.ts +590 -0
  41. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  42. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  43. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  44. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  45. package/src/background-messages.ts +169 -0
  46. package/src/channel-frames.ts +182 -0
  47. package/src/client.ts +9 -0
  48. package/src/handoff-chunking.ts +235 -0
  49. package/src/index.ts +112 -3
  50. package/src/message-decoders.ts +229 -0
  51. package/src/message-parser.ts +33 -0
  52. package/src/node/access-token-verifier.ts +363 -0
  53. package/src/node/admission.ts +153 -0
  54. package/src/node/handoff-manifest.ts +77 -0
  55. package/src/node/index.ts +10 -0
  56. package/src/outbound-delivery.ts +231 -0
  57. package/src/peer-message-ledger.ts +193 -0
  58. package/src/protocol-session.ts +28 -0
  59. package/src/resume-buffer.ts +123 -0
  60. package/src/session-events.ts +198 -0
  61. package/src/session-message-handler.ts +282 -0
  62. package/src/session-query-messages.ts +42 -0
  63. package/src/session-resume-bridge.ts +252 -0
  64. package/src/usage-messages.ts +195 -0
  65. package/src/wire-messages.ts +165 -0
  66. package/dist/node/headless/index.cjs +0 -1
  67. package/dist/node/headless/index.d.ts +0 -2
  68. package/dist/node/headless/index.js +0 -1
  69. package/dist/node/headless-DG_MEbf0.js +0 -19
  70. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  71. package/dist/node/headless-xq77JBq0.cjs +0 -18
  72. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  73. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  74. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  75. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  76. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  77. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  78. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  79. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  80. package/dist/node/programmatic/index.cjs +0 -1
  81. package/dist/node/programmatic/index.d.ts +0 -2
  82. package/dist/node/programmatic/index.js +0 -1
  83. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  84. package/dist/node/programmatic-70dxIXtS.js +0 -2
  85. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  86. package/dist/node/testing/index.cjs +0 -1
  87. package/dist/node/testing/index.d.ts +0 -2
  88. package/dist/node/testing/index.js +0 -1
  89. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  90. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  91. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  92. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  93. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  94. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  95. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  96. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  97. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  98. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  99. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  100. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  101. package/src/headless/cli-input.ts +0 -50
  102. package/src/headless/headless-runner.ts +0 -230
  103. package/src/headless/headless-stream-json.ts +0 -148
  104. package/src/headless/headless-transport.ts +0 -43
  105. package/src/headless/index.ts +0 -8
  106. package/src/headless/print-terminal.ts +0 -57
  107. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  108. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  109. package/src/programmatic/index.ts +0 -5
  110. package/src/testing/index.ts +0 -10
  111. package/src/transport-registry.ts +0 -103
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The connection-scoped outbound delivery boundary (ARCH-030).
3
+ *
4
+ * Every outbound `TServerMessage` on one connection — session-event fan-out AND every reply to an
5
+ * inbound frame — goes through ONE boundary that owns carrier-failure containment. Before this
6
+ * existed, `subscribeSessionEvents` guarded its sends while the reply paths received the raw carrier
7
+ * `send`, so a reply resolving after a disconnect threw from a Promise continuation, escaped as an
8
+ * unhandled rejection, and left the carrier's cleanup — written, idempotent, and waiting — unnotified.
9
+ *
10
+ * WHO BUILDS IT. The **carrier**, from its own private sink and its own failure policy, and passes the
11
+ * result down into the protocol handler. Not the other way round: the raw sink and the "what does a
12
+ * failed send mean for this connection" decision both belong to the carrier, and handing the protocol
13
+ * layer a raw sink so it can hand a wrapper back leaves the raw sink reachable — which is the hole a
14
+ * twelfth reply family walks through next year.
15
+ *
16
+ * WHY IT IS BRANDED. {@link createOutboundDelivery} is the only producer of {@link TOutboundDeliver}, so
17
+ * a plain `(message: TServerMessage) => void` is **refused by the compiler** wherever a boundary is
18
+ * required. That turns "a new raw-send continuation cannot be added silently" from a scan that a rename
19
+ * defeats into a type error at the call site. `src/__tests__` is inside this package's `tsconfig.json`
20
+ * `include`, so the `@ts-expect-error` case pinning it is checked by `tsgo --noEmit`.
21
+ *
22
+ * IT LATCHES. A boundary reports AT MOST ONE delivery failure; after the first, it is closed and every
23
+ * subsequent frame is dropped without a further report. All three carriers already treat a delivery
24
+ * failure as terminal (`WsSessionDelivery.close` → `socket.close(1011)`, `WebRtcDeliveryLifecycle` →
25
+ * `channel.close()`, `PairingGate` → `pairingChannel.close`) and each had grown its own latch to
26
+ * suppress the repeats; the latch belongs upstream of all three. `SessionResumeBridge` already behaved
27
+ * this way, so the handler path converges onto the bridge's semantics rather than the reverse.
28
+ *
29
+ * A boundary is not reusable across carriers: a latched one stays latched. `SessionResumeBridge` builds
30
+ * a fresh boundary per `attach`, which is what un-latches the session after a reconnect.
31
+ */
32
+
33
+ import type { TServerMessage } from './wire-messages.js';
34
+
35
+ declare const outboundDeliveryBrand: unique symbol;
36
+
37
+ /**
38
+ * Deliver one outbound frame on a connection. A carrier failure is REPORTED through the boundary's
39
+ * error handler, never thrown at the caller — so a reply resolving after a disconnect cannot escape as
40
+ * an unhandled rejection or out of the carrier's inbound listener.
41
+ *
42
+ * Only {@link createOutboundDelivery} produces one. The brand is the mechanism, not decoration: it is
43
+ * what makes a raw carrier `send` unusable where a boundary is required.
44
+ */
45
+ export type TOutboundDeliver = ((message: TServerMessage) => void) & {
46
+ readonly [outboundDeliveryBrand]: true;
47
+
48
+ /**
49
+ * Bytes this carrier has ACCEPTED and not yet written to the peer — or `undefined` when it cannot
50
+ * say.
51
+ *
52
+ * `deliver` returning is not delivery. Both carriers hand this boundary a fire-and-forget sink, so
53
+ * a frame that has "been sent" may be sitting in a socket buffer the boundary cannot see. Anything
54
+ * this boundary counted itself would be a count of what it HANDED OVER, which is a different
55
+ * quantity from what the peer has not read — and a budget over the wrong quantity reports a
56
+ * healthy connection for a peer that stopped reading (ARCH-030 / issue #1734).
57
+ *
58
+ * `undefined` is deliberately not `0`. A carrier that cannot report backpressure and a carrier with
59
+ * nothing pending are different states, and collapsing them would let "unknown" satisfy any
60
+ * threshold placed on this number.
61
+ */
62
+ readonly pendingBytes: () => number | undefined;
63
+ };
64
+
65
+ /**
66
+ * Observe THIS connection's first outbound delivery failure. `event` is the `type` of the frame that
67
+ * could not be delivered — a session event's own name for the fan-out (they are identical), and the
68
+ * reply's type for a reply (`command_result`, `protocol_error`, …).
69
+ *
70
+ * Required, never optional: a carrier that could opt out of observing its own delivery failures is the
71
+ * silent-failure shape this boundary exists to remove.
72
+ */
73
+ export type TDeliveryErrorHandler = (error: Error, event: TServerMessage['type']) => void;
74
+
75
+ /**
76
+ * Build the outbound delivery boundary for ONE connection from that connection's raw sink and its
77
+ * failure policy.
78
+ *
79
+ * @param send - the carrier's raw sink. May throw; that is what makes it raw.
80
+ * @param onDeliveryError - the carrier's failure policy, invoked at most once. A handler that itself
81
+ * throws is isolated: a diagnostic cannot reverse an already-committed session operation.
82
+ * @param pendingBytes - the carrier's own backpressure reading. Optional because not every carrier
83
+ * has one; omitted, the boundary answers `undefined` rather than inventing a number.
84
+ */
85
+ /**
86
+ * Default outbound backpressure budget, in bytes the carrier has accepted and not yet written.
87
+ *
88
+ * 8 MiB. The number is a policy, not a measurement, and it is stated here rather than inlined so a
89
+ * reader can find the one place that decides it. It is well above any single frame this protocol
90
+ * produces and well below a figure at which a non-reading peer could exhaust the host — the property
91
+ * that matters is that SOME finite budget exists, because the previous behaviour was unbounded.
92
+ *
93
+ * A carrier that cannot report `pendingBytes` is not subject to it: `undefined` is unknown, not zero,
94
+ * so no threshold applies to it. That is deliberate and it is a gap — a carrier with no backpressure
95
+ * reading has no budget — recorded rather than hidden behind a default of `0`, which would refuse
96
+ * every frame on such a carrier.
97
+ */
98
+ const BYTES_PER_MIB = Number('1024') * Number('1024');
99
+ export const DEFAULT_MAX_PENDING_BYTES = Number('8') * BYTES_PER_MIB;
100
+
101
+ /**
102
+ * Is this carrier holding more than the budget allows?
103
+ *
104
+ * Exported because a WebSocket carries TWO kinds of outbound frame over one socket — the text
105
+ * protocol and TRANS-001's payload channels — and only one of them goes through
106
+ * {@link createOutboundDelivery}. A budget the binary path re-implemented would be a second opinion
107
+ * about one socket's backpressure; a budget it skipped would leave the same non-reading peer
108
+ * unbounded on the other half of the connection.
109
+ *
110
+ * `undefined` is unknown, not zero: a carrier that cannot report backpressure is never over budget.
111
+ */
112
+ export function isOverPendingBudget(
113
+ pending: number | undefined,
114
+ limit: number = DEFAULT_MAX_PENDING_BYTES,
115
+ ): boolean {
116
+ return pending !== undefined && pending > limit;
117
+ }
118
+
119
+ /**
120
+ * Default outbound DRAIN budget (issue #2306): how long a carrier may stay non-empty before the peer
121
+ * is treated as not reading.
122
+ *
123
+ * 120 seconds. The byte budget bounds HOW MUCH a peer holds; it cannot bound HOW LONG, because
124
+ * `bufferedAmount` is a quantity with no history — a peer draining just enough to stay under 8 MiB
125
+ * forever is never over it. The figure is generous on purpose: a legitimately slow link drains to
126
+ * zero between bursts, so the clock resets, whereas a peer that keeps SOMETHING pending for two
127
+ * minutes straight is holding a session, its resume buffer, and a socket it does not intend to read.
128
+ * A badly chosen figure here disconnects real users, which is why it is one named constant and an
129
+ * argument, never an inline number. `Infinity` disables the budget.
130
+ */
131
+ export const DEFAULT_MAX_PENDING_MS = Number('120') * Number('1000');
132
+
133
+ /**
134
+ * The drain clock the boundary keeps for a carrier (issue #2306). One timestamp, not per-frame
135
+ * bookkeeping: it starts on the first reading that finds the carrier non-empty, RESETS only when a
136
+ * reading finds it empty (a peer that drains to one byte and stalls again has not drained), and is
137
+ * consulted synchronously at each send — the boundary has no timer and gains none here.
138
+ */
139
+ export interface IPendingStallClock {
140
+ readonly maxPendingMs: number;
141
+ /**
142
+ * Record one reading of the carrier's pending bytes. Returns how long the carrier has been
143
+ * continuously non-empty when that exceeds the budget; `undefined` otherwise. An `undefined`
144
+ * reading is unknown, not zero: it neither starts nor resets the clock.
145
+ */
146
+ observe: (pending: number | undefined) => number | undefined;
147
+ }
148
+
149
+ export function createPendingStallClock(
150
+ maxPendingMs: number = DEFAULT_MAX_PENDING_MS,
151
+ now: () => number = Date.now,
152
+ ): IPendingStallClock {
153
+ let nonEmptySince: number | undefined;
154
+ return {
155
+ maxPendingMs,
156
+ observe(pending) {
157
+ if (pending === undefined) return undefined;
158
+ if (pending === 0) {
159
+ nonEmptySince = undefined;
160
+ return undefined;
161
+ }
162
+ const at = now();
163
+ nonEmptySince ??= at;
164
+ const stalledMs = at - nonEmptySince;
165
+ return stalledMs > maxPendingMs ? stalledMs : undefined;
166
+ },
167
+ };
168
+ }
169
+
170
+ export function createOutboundDelivery(
171
+ send: (message: TServerMessage) => void,
172
+ onDeliveryError: TDeliveryErrorHandler,
173
+ pendingBytes?: () => number | undefined,
174
+ maxPendingBytes: number = DEFAULT_MAX_PENDING_BYTES,
175
+ stallClock: IPendingStallClock = createPendingStallClock(),
176
+ ): TOutboundDeliver {
177
+ let closed = false;
178
+ const deliver = (message: TServerMessage): void => {
179
+ if (closed) return;
180
+ // The budget is checked BEFORE the send, not after: a peer that has stopped reading is detected
181
+ // by what the carrier is still holding, and adding one more frame first would make the boundary
182
+ // the last contributor to the overflow it is reporting.
183
+ const pending = pendingBytes?.();
184
+ if (isOverPendingBudget(pending, maxPendingBytes)) {
185
+ closed = true;
186
+ reportFailure(
187
+ new Error(
188
+ `outbound backpressure budget exceeded: ${pending} byte(s) pending, limit ${maxPendingBytes}. ` +
189
+ 'The peer has accepted frames it is not reading.',
190
+ ),
191
+ message.type,
192
+ );
193
+ return;
194
+ }
195
+ // Issue #2306: the same reading, judged by duration. A peer under the byte budget that has kept
196
+ // the carrier non-empty for longer than the drain budget is not reading either.
197
+ const stalledMs = stallClock.observe(pending);
198
+ if (stalledMs !== undefined) {
199
+ closed = true;
200
+ reportFailure(
201
+ new Error(
202
+ `outbound drain budget exceeded: ${pending} byte(s) pending for ${stalledMs} ms, limit ${stallClock.maxPendingMs} ms. ` +
203
+ 'The peer is reading too slowly to ever drain what it accepted.',
204
+ ),
205
+ message.type,
206
+ );
207
+ return;
208
+ }
209
+ try {
210
+ send(message);
211
+ } catch (error) {
212
+ // allow-fallback: this IS the boundary — a carrier send failure is a CARRIER lifecycle failure,
213
+ // reported to the carrier's own policy (which closes the connection). It is never swallowed, and
214
+ // re-throwing it would fail a session operation that has already committed.
215
+ closed = true;
216
+ reportFailure(error instanceof Error ? error : new Error(String(error)), message.type);
217
+ }
218
+ };
219
+ function reportFailure(error: Error, event: TServerMessage['type']): void {
220
+ try {
221
+ onDeliveryError(error, event);
222
+ } catch {
223
+ // allow-fallback: the carrier-owned diagnostic is the LAST step of a failure already being
224
+ // reported. A throw from it has nowhere left to go, and letting it out would make a committed
225
+ // session operation fail for the sake of a diagnostic.
226
+ }
227
+ }
228
+ return Object.assign(deliver, {
229
+ pendingBytes: (): number | undefined => pendingBytes?.(),
230
+ }) as TOutboundDeliver;
231
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * PEER-001 (#1809): the receiver's record of what it has already taken responsibility for.
3
+ *
4
+ * ## What this decides, and why it is not the carrier's job
5
+ *
6
+ * The issue requires delivery, acknowledgement, duplicate, retry and shutdown to produce
7
+ * "deterministic, documented outcomes". Every one of those is a question about what the receiver has
8
+ * SEEN BEFORE, which no carrier can answer: a WebRTC data channel redelivers on reconnect, a retry
9
+ * repeats a message the sender already sent, and neither the socket nor the frame codec has any
10
+ * memory to consult. So the decision lives here, once, and the carriers stay dumb.
11
+ *
12
+ * ## The rules, stated rather than emergent
13
+ *
14
+ * - **A repeated `id` is a duplicate**, and the ledger returns the SAME ack it returned the first
15
+ * time. That is what makes a retry safe: the sender learns the message landed, and the receiver
16
+ * does not deliver it twice. An ack that merely said "duplicate" without the original state would
17
+ * force the sender to guess whether the first attempt was accepted or refused.
18
+ * - **Sequence is per origin.** Two peers are independent senders; a shared counter would make one
19
+ * peer's traffic look like the other's gap.
20
+ * - **A gap is REPORTED, never silently reordered.** If sequence 5 arrives while 4 has not, the
21
+ * ledger says so. Buffering-and-reordering is a policy the session layer may choose; inventing it
22
+ * here would hide a lost message behind an apparent success, which is the failure mode this whole
23
+ * contract exists to avoid.
24
+ * - **Nothing is remembered across shutdown.** The ledger is per-connection state by construction —
25
+ * a peer that reconnects gets a fresh sequence space, and the session layer decides what to do
26
+ * with a peer that restarts mid-conversation.
27
+ */
28
+
29
+ import type {
30
+ IPeerMessage,
31
+ IPeerMessageAck,
32
+ TPeerDeliveryState,
33
+ } from '@robota-sdk/agent-interface-session-mobility';
34
+
35
+ /**
36
+ * What the receiver has seen from one origin.
37
+ *
38
+ * `delivered` is a SET, not just a high-water mark, and that distinction is the whole of review
39
+ * finding #1. A high-water mark cannot tell "sequence 2 was already used" from "sequence 2 is the
40
+ * gap we reported and it has just arrived" — and answering the second as if it were the first
41
+ * refuses a message that was never delivered, permanently. `highestSequence` is kept alongside it
42
+ * because reporting a gap needs the frontier, not the membership.
43
+ *
44
+ * Exported because it is reachable from `IPeerMessageLedger`. A public interface whose field type is
45
+ * unexported is a name a consumer cannot write down (review finding #2). Measured: `tsdown` inlines
46
+ * it into the bundled declaration so no TS4033 is raised today — the finding's build-breakage claim
47
+ * does not reproduce — but a type nobody can name is still a bad public surface.
48
+ */
49
+ export interface IOriginRecord {
50
+ /** The highest sequence delivered from this origin — the frontier a gap is measured against. */
51
+ highestSequence: number;
52
+ /** Every sequence actually delivered. Membership, not a range: gaps get filled out of order. */
53
+ delivered: Set<number>;
54
+ /** Acks already issued, by message id, so a retry gets the original answer back. */
55
+ issued: Map<string, IPeerMessageAck>;
56
+ }
57
+
58
+ /** Per-connection receive state. Created empty; discarded when the connection ends. */
59
+ export interface IPeerMessageLedger {
60
+ readonly origins: Map<string, IOriginRecord>;
61
+ }
62
+
63
+ export function createPeerMessageLedger(): IPeerMessageLedger {
64
+ return { origins: new Map() };
65
+ }
66
+
67
+ /** Why a message was not accepted, when it was not. */
68
+ export interface IPeerMessageRejection {
69
+ readonly state: Extract<TPeerDeliveryState, 'refused' | 'failed'>;
70
+ readonly reason: string;
71
+ }
72
+
73
+ export interface IPeerMessageVerdict {
74
+ readonly ack: IPeerMessageAck;
75
+ /**
76
+ * Should the session deliver this to the agent?
77
+ *
78
+ * False for a duplicate and for a refusal. Separate from `ack.state` because a caller that
79
+ * re-derived it from the state string would have to re-list which states mean "deliver", and that
80
+ * list is exactly what drifts.
81
+ */
82
+ readonly deliver: boolean;
83
+ /**
84
+ * Set when this message's sequence is ahead of the last one delivered from its origin.
85
+ *
86
+ * Reported, not resolved. The session layer decides whether a gap is worth waiting for; the
87
+ * ledger's job is to make sure it is never invisible.
88
+ */
89
+ readonly gapBefore?: number;
90
+ }
91
+
92
+ /**
93
+ * Record a message and say what should happen to it.
94
+ *
95
+ * Pure with respect to its inputs except for the ledger it is handed, which it updates in place —
96
+ * the caller owns the ledger's lifetime, so a connection ending is a reference going away rather
97
+ * than a cleanup step someone can forget.
98
+ */
99
+ export function admitPeerMessage(
100
+ ledger: IPeerMessageLedger,
101
+ message: IPeerMessage,
102
+ rejection?: IPeerMessageRejection,
103
+ ): IPeerMessageVerdict {
104
+ const originId = message.origin.sessionId;
105
+ let record = ledger.origins.get(originId);
106
+ if (record === undefined) {
107
+ record = { highestSequence: 0, delivered: new Set(), issued: new Map() };
108
+ ledger.origins.set(originId, record);
109
+ }
110
+
111
+ // A repeated id is answered with the ORIGINAL verdict, before anything else is considered: a
112
+ // retry must not be re-judged, or a message accepted once could be refused on its second arrival
113
+ // and the sender would have two contradictory answers for one message.
114
+ const previous = record.issued.get(message.id);
115
+ if (previous !== undefined) {
116
+ return { ack: { ...previous, state: 'duplicate' }, deliver: false };
117
+ }
118
+
119
+ if (rejection !== undefined) {
120
+ const ack: IPeerMessageAck = {
121
+ id: message.id,
122
+ sequence: message.sequence,
123
+ state: rejection.state,
124
+ reason: rejection.reason,
125
+ };
126
+ record.issued.set(message.id, ack);
127
+ return { ack, deliver: false };
128
+ }
129
+
130
+ // A sequence this origin ACTUALLY delivered before, under a different id: a sender reusing a
131
+ // sequence, which is a protocol error rather than a duplicate.
132
+ //
133
+ // Membership, not `<= highestSequence`. The high-water form refused the late arrival of a gap it
134
+ // had itself reported — deliver 1, receive 3 (gap at 2 reported), then 2 arrives and is rejected
135
+ // as "already used" although it was never delivered. That contradicted this module's own stated
136
+ // rule that a gap is reported so the SESSION layer can decide, since the session never got the
137
+ // chance. Found in review.
138
+ if (record.delivered.has(message.sequence)) {
139
+ const ack: IPeerMessageAck = {
140
+ id: message.id,
141
+ sequence: message.sequence,
142
+ state: 'refused',
143
+ reason:
144
+ `sequence ${message.sequence} was already delivered by this origin under a different ` +
145
+ 'message id. A retry must repeat its id; a new id on a delivered sequence cannot be ' +
146
+ 'ordered against what the receiver already has.',
147
+ };
148
+ record.issued.set(message.id, ack);
149
+ return { ack, deliver: false };
150
+ }
151
+
152
+ const expected = record.highestSequence + 1;
153
+ const gapBefore = message.sequence > expected ? expected : undefined;
154
+
155
+ const ack: IPeerMessageAck = {
156
+ id: message.id,
157
+ sequence: message.sequence,
158
+ state: 'delivered',
159
+ };
160
+ record.issued.set(message.id, ack);
161
+ record.delivered.add(message.sequence);
162
+ // The frontier only moves FORWARD. A gap arriving late fills membership without rewinding what a
163
+ // later gap is measured against.
164
+ record.highestSequence = Math.max(record.highestSequence, message.sequence);
165
+
166
+ return gapBefore === undefined ? { ack, deliver: true } : { ack, deliver: true, gapBefore };
167
+ }
168
+
169
+ /**
170
+ * Mark a delivered message as acknowledged by the agent side.
171
+ *
172
+ * Separate from `admitPeerMessage` because delivery and acknowledgement answer different questions —
173
+ * "did the receiver take it" versus "did the agent act on it" — and a sender waiting for the second
174
+ * must not be told the first.
175
+ */
176
+ export function acknowledgePeerMessage(
177
+ ledger: IPeerMessageLedger,
178
+ originSessionId: string,
179
+ id: string,
180
+ ): IPeerMessageAck | undefined {
181
+ const record = ledger.origins.get(originSessionId);
182
+ const issued = record?.issued.get(id);
183
+ if (record === undefined || issued === undefined || issued.state !== 'delivered')
184
+ return undefined;
185
+ const acknowledged: IPeerMessageAck = { ...issued, state: 'acknowledged' };
186
+ record.issued.set(id, acknowledged);
187
+ return acknowledged;
188
+ }
189
+
190
+ /** Forget one origin — used when a peer disconnects and its sequence space is no longer meaningful. */
191
+ export function forgetPeerOrigin(ledger: IPeerMessageLedger, originSessionId: string): void {
192
+ ledger.origins.delete(originSessionId);
193
+ }
@@ -0,0 +1,28 @@
1
+ import type {
2
+ ISessionBackgroundGroups,
3
+ ISessionBackgroundTasks,
4
+ ISessionCommands,
5
+ ISessionConversationRead,
6
+ ISessionDriverAttribution,
7
+ ISessionEvents,
8
+ ISessionExecutionState,
9
+ ISessionExecutionWorkspace,
10
+ ISessionPromptResolution,
11
+ ISessionTurnControl,
12
+ ISessionTurnSubmission,
13
+ } from '@robota-sdk/agent-interface-session';
14
+
15
+ /** Session roles required by the shared WebSocket/WebRTC protocol. */
16
+ export interface IProtocolSession
17
+ extends
18
+ ISessionTurnSubmission,
19
+ ISessionTurnControl,
20
+ ISessionCommands,
21
+ ISessionEvents,
22
+ ISessionPromptResolution,
23
+ ISessionConversationRead,
24
+ ISessionExecutionState,
25
+ ISessionDriverAttribution,
26
+ ISessionBackgroundTasks,
27
+ ISessionBackgroundGroups,
28
+ ISessionExecutionWorkspace {}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Bounded un-acked output buffer for session-resume (REMOTE-013 Stage E4).
3
+ *
4
+ * Holds the tail of outbound `TServerMessage`s that a reconnecting client may not have received, keyed by a
5
+ * monotonic sequence number. On reconnect the client sends `resume{lastSeq}` and the host replays
6
+ * {@link ResumeBuffer.tailAfter}; a periodic `ack{seq}` frees everything up to `seq`. The buffer is bounded by
7
+ * a frame count AND a byte budget (drop-oldest) so a never-acking or gone peer cannot exhaust host memory —
8
+ * and when a client's `lastSeq` predates the oldest retained frame it returns an **overrun** marker so the
9
+ * client does a full refresh rather than accept a silent gap.
10
+ */
11
+
12
+ import type { TServerMessage } from './wire-messages.js';
13
+
14
+ /** One retained frame: its sequence number and the message. */
15
+ export interface IBufferedFrame {
16
+ readonly seq: number;
17
+ readonly message: TServerMessage;
18
+ }
19
+
20
+ /** The result of a `tailAfter` — either the ordered replay slice, or an overrun (client must full-refresh). */
21
+ export type TResumeTail =
22
+ | { readonly kind: 'tail'; readonly frames: readonly IBufferedFrame[] }
23
+ | { readonly kind: 'overrun' };
24
+
25
+ export interface IResumeBufferOptions {
26
+ /** Max retained frames (drop-oldest beyond this). Default 512. */
27
+ readonly maxFrames?: number;
28
+ /** Max retained bytes across frames (drop-oldest beyond this). Default 4 MiB. */
29
+ readonly maxBytes?: number;
30
+ /** Serialized size of a message (for the byte budget). Default: JSON length. Injectable for tests. */
31
+ readonly sizeOf?: (message: TServerMessage) => number;
32
+ }
33
+
34
+ const DEFAULT_MAX_FRAMES = 512;
35
+ const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
36
+
37
+ /**
38
+ * A monotonic, bounded ring of un-acked frames. Sequence numbers are assigned by {@link append} and never
39
+ * reused; `tailAfter` / `ackThrough` operate over the retained window.
40
+ */
41
+ export class ResumeBuffer {
42
+ private readonly frames: IBufferedFrame[] = [];
43
+ private readonly sizes: number[] = []; // parallel to `frames` — retained byte size per frame
44
+ private nextSeq = 1;
45
+ private bytes = 0;
46
+
47
+ private readonly maxFrames: number;
48
+ private readonly maxBytes: number;
49
+ private readonly sizeOf: (message: TServerMessage) => number;
50
+
51
+ public constructor(options: IResumeBufferOptions = {}) {
52
+ this.maxFrames = options.maxFrames ?? DEFAULT_MAX_FRAMES;
53
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
54
+ this.sizeOf = options.sizeOf ?? ((m) => JSON.stringify(m).length);
55
+ }
56
+
57
+ /** Append a message, assign + return its monotonic seq, and evict oldest frames beyond the bounds. */
58
+ public append(message: TServerMessage): number {
59
+ const seq = this.nextSeq;
60
+ this.nextSeq += 1;
61
+ const size = this.sizeOf(message);
62
+ this.frames.push({ seq, message });
63
+ this.sizes.push(size);
64
+ this.bytes += size;
65
+ this.evict();
66
+ return seq;
67
+ }
68
+
69
+ /** Drop all retained frames with `seq <= throughSeq` (the client confirmed receipt up to there). */
70
+ public ackThrough(throughSeq: number): void {
71
+ let drop = 0;
72
+ while (drop < this.frames.length && this.frames[drop].seq <= throughSeq) drop += 1;
73
+ this.removeOldest(drop);
74
+ }
75
+
76
+ /**
77
+ * The ordered frames with `seq > lastSeq`. Returns an `overrun` marker when `lastSeq` is older than the
78
+ * oldest retained frame (i.e. some frame after `lastSeq` was already evicted) — the client must full-refresh.
79
+ * `lastSeq` at or beyond the newest seq yields an empty tail (nothing to replay).
80
+ */
81
+ public tailAfter(lastSeq: number): TResumeTail {
82
+ if (this.frames.length === 0) {
83
+ // Nothing retained. Only an overrun if the client is behind the frames we HAVE emitted+evicted.
84
+ return lastSeq >= this.nextSeq - 1 ? { kind: 'tail', frames: [] } : { kind: 'overrun' };
85
+ }
86
+ const oldest = this.frames[0].seq;
87
+ if (lastSeq < oldest - 1) return { kind: 'overrun' };
88
+ return { kind: 'tail', frames: this.frames.filter((f) => f.seq > lastSeq) };
89
+ }
90
+
91
+ /** The last seq assigned (0 when nothing has been appended). */
92
+ public get lastSeq(): number {
93
+ return this.nextSeq - 1;
94
+ }
95
+
96
+ /** Retained frame count (diagnostics/tests). */
97
+ public get size(): number {
98
+ return this.frames.length;
99
+ }
100
+
101
+ private evict(): void {
102
+ let drop = 0;
103
+ let remainingBytes = this.bytes;
104
+ // Evict oldest while over the frame cap, or over the byte cap (but always keep at least the newest frame).
105
+ while (
106
+ this.frames.length - drop > this.maxFrames ||
107
+ (remainingBytes > this.maxBytes && this.frames.length - drop > 1)
108
+ ) {
109
+ remainingBytes -= this.sizes[drop];
110
+ drop += 1;
111
+ }
112
+ this.removeOldest(drop);
113
+ }
114
+
115
+ /** The single place that mutates `frames`/`sizes`/`bytes` for removal, so byte accounting can't drift. */
116
+ private removeOldest(count: number): void {
117
+ if (count <= 0) return;
118
+ for (let i = 0; i < count; i += 1) this.bytes -= this.sizes[i] ?? 0;
119
+ this.frames.splice(0, count);
120
+ this.sizes.splice(0, count);
121
+ if (this.frames.length === 0) this.bytes = 0;
122
+ }
123
+ }