@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.
- package/README.md +84 -26
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +520 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
- package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
- package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +766 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +765 -27
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +109 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +109 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +68 -47
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/channel-frames.test.ts +214 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/handoff-chunking.test.ts +215 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/message-decoders.test.ts +249 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler.test.ts +590 -0
- package/src/__tests__/session-resume-bridge.test.ts +406 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +84 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +9 -0
- package/src/handoff-chunking.ts +235 -0
- package/src/index.ts +112 -3
- package/src/message-decoders.ts +229 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +10 -0
- package/src/outbound-delivery.ts +231 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/protocol-session.ts +28 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-events.ts +198 -0
- package/src/session-message-handler.ts +282 -0
- package/src/session-query-messages.ts +42 -0
- package/src/session-resume-bridge.ts +252 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +165 -0
- package/dist/node/headless/index.cjs +0 -1
- package/dist/node/headless/index.d.ts +0 -2
- package/dist/node/headless/index.js +0 -1
- package/dist/node/headless-DG_MEbf0.js +0 -19
- package/dist/node/headless-DG_MEbf0.js.map +0 -1
- package/dist/node/headless-xq77JBq0.cjs +0 -18
- package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
- package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
- package/dist/node/index-JIvzrtaA.d.ts +0 -52
- package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
- package/dist/node/index-JTD_xtEQ.d.ts +0 -96
- package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
- package/dist/node/index-y_HBOfzO.d.ts +0 -52
- package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
- package/dist/node/programmatic/index.cjs +0 -1
- package/dist/node/programmatic/index.d.ts +0 -2
- package/dist/node/programmatic/index.js +0 -1
- package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
- package/dist/node/programmatic-70dxIXtS.js +0 -2
- package/dist/node/programmatic-70dxIXtS.js.map +0 -1
- package/dist/node/testing/index.cjs +0 -1
- package/dist/node/testing/index.d.ts +0 -2
- package/dist/node/testing/index.js +0 -1
- package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
- package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
- package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
- package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
- package/src/headless/HeadlessInteractionChannel.ts +0 -129
- package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
- package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
- package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
- package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
- package/src/headless/__tests__/headless-runner.test.ts +0 -508
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
- package/src/headless/__tests__/headless-transport.test.ts +0 -267
- package/src/headless/cli-input.ts +0 -50
- package/src/headless/headless-runner.ts +0 -230
- package/src/headless/headless-stream-json.ts +0 -148
- package/src/headless/headless-transport.ts +0 -43
- package/src/headless/index.ts +0 -8
- package/src/headless/print-terminal.ts +0 -57
- package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
- package/src/programmatic/createProgrammaticAgent.ts +0 -71
- package/src/programmatic/index.ts +0 -5
- package/src/testing/index.ts +0 -10
- 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
|
+
}
|