@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,169 @@
1
+ import type { TOutboundDeliver } from './outbound-delivery.js';
2
+ import type { IProtocolSession } from './protocol-session.js';
3
+ import type { TBackgroundControlAction, TClientMessage } from './wire-messages.js';
4
+
5
+ export function handleBackgroundQueryMessage(
6
+ session: IProtocolSession,
7
+ deliver: TOutboundDeliver,
8
+ msg: Extract<
9
+ TClientMessage,
10
+ | { type: 'get-background-tasks' | 'get-background-task' | 'read-background-task-log' }
11
+ | {
12
+ type:
13
+ 'get-background-job-groups' | 'get-background-job-group' | 'wait-background-job-group';
14
+ }
15
+ >,
16
+ ): void {
17
+ if (msg.type === 'get-background-tasks') {
18
+ deliver({ type: 'background_tasks', tasks: session.listBackgroundTasks(msg.filter) });
19
+ return;
20
+ }
21
+ if (msg.type === 'get-background-task') {
22
+ sendBackgroundTaskSnapshot(session, deliver, msg);
23
+ return;
24
+ }
25
+ if (msg.type === 'get-background-job-groups') {
26
+ deliver({ type: 'background_job_groups', groups: session.listBackgroundJobGroups() });
27
+ return;
28
+ }
29
+ if (msg.type === 'get-background-job-group') {
30
+ sendBackgroundJobGroupSnapshot(session, deliver, msg);
31
+ return;
32
+ }
33
+ if (msg.type === 'wait-background-job-group') {
34
+ sendBackgroundJobGroupWaitResult(session, deliver, msg);
35
+ return;
36
+ }
37
+ sendBackgroundTaskLogPage(session, deliver, msg);
38
+ }
39
+
40
+ export function handleBackgroundControlMessage(
41
+ session: IProtocolSession,
42
+ deliver: TOutboundDeliver,
43
+ msg: Extract<
44
+ TClientMessage,
45
+ { type: 'cancel-background-task' | 'close-background-task' | 'send-background-task' }
46
+ >,
47
+ ): void {
48
+ if (!msg.taskId) {
49
+ deliver({ type: 'protocol_error', message: 'taskId is required' });
50
+ return;
51
+ }
52
+ if (msg.type === 'cancel-background-task') {
53
+ sendBackgroundTaskControlResult(
54
+ deliver,
55
+ 'cancel',
56
+ msg.taskId,
57
+ session.cancelBackgroundTask(msg.taskId, msg.reason),
58
+ );
59
+ return;
60
+ }
61
+ if (msg.type === 'close-background-task') {
62
+ sendBackgroundTaskControlResult(
63
+ deliver,
64
+ 'close',
65
+ msg.taskId,
66
+ session.closeBackgroundTask(msg.taskId),
67
+ );
68
+ return;
69
+ }
70
+ sendBackgroundTaskInput(session, deliver, msg);
71
+ }
72
+
73
+ function sendBackgroundTaskSnapshot(
74
+ session: IProtocolSession,
75
+ deliver: TOutboundDeliver,
76
+ msg: Extract<TClientMessage, { type: 'get-background-task' }>,
77
+ ): void {
78
+ if (!msg.taskId) {
79
+ deliver({ type: 'protocol_error', message: 'taskId is required' });
80
+ return;
81
+ }
82
+ deliver({
83
+ type: 'background_task',
84
+ taskId: msg.taskId,
85
+ task: session.getBackgroundTask(msg.taskId) ?? null,
86
+ });
87
+ }
88
+
89
+ function sendBackgroundTaskLogPage(
90
+ session: IProtocolSession,
91
+ deliver: TOutboundDeliver,
92
+ msg: Extract<TClientMessage, { type: 'read-background-task-log' }>,
93
+ ): void {
94
+ if (!msg.taskId) {
95
+ deliver({ type: 'protocol_error', message: 'taskId is required' });
96
+ return;
97
+ }
98
+ session.readBackgroundTaskLog(msg.taskId, msg.cursor).then(
99
+ (page) => deliver({ type: 'background_task_log', taskId: msg.taskId, page }),
100
+ (error: Error) => deliver({ type: 'protocol_error', message: error.message }),
101
+ );
102
+ }
103
+
104
+ function sendBackgroundJobGroupSnapshot(
105
+ session: IProtocolSession,
106
+ deliver: TOutboundDeliver,
107
+ msg: Extract<TClientMessage, { type: 'get-background-job-group' }>,
108
+ ): void {
109
+ if (!msg.groupId) {
110
+ deliver({ type: 'protocol_error', message: 'groupId is required' });
111
+ return;
112
+ }
113
+ deliver({
114
+ type: 'background_job_group',
115
+ groupId: msg.groupId,
116
+ group: session.getBackgroundJobGroup(msg.groupId) ?? null,
117
+ });
118
+ }
119
+
120
+ function sendBackgroundJobGroupWaitResult(
121
+ session: IProtocolSession,
122
+ deliver: TOutboundDeliver,
123
+ msg: Extract<TClientMessage, { type: 'wait-background-job-group' }>,
124
+ ): void {
125
+ if (!msg.groupId) {
126
+ deliver({ type: 'protocol_error', message: 'groupId is required' });
127
+ return;
128
+ }
129
+ session.waitBackgroundJobGroup(msg.groupId).then(
130
+ (group) => deliver({ type: 'background_job_group', groupId: msg.groupId, group }),
131
+ (error: Error) => deliver({ type: 'protocol_error', message: error.message }),
132
+ );
133
+ }
134
+
135
+ function sendBackgroundTaskInput(
136
+ session: IProtocolSession,
137
+ deliver: TOutboundDeliver,
138
+ msg: Extract<TClientMessage, { type: 'send-background-task' }>,
139
+ ): void {
140
+ if (!msg.input) {
141
+ deliver({ type: 'protocol_error', message: 'input is required' });
142
+ return;
143
+ }
144
+ sendBackgroundTaskControlResult(
145
+ deliver,
146
+ 'send',
147
+ msg.taskId,
148
+ session.sendBackgroundTask(msg.taskId, msg.input),
149
+ );
150
+ }
151
+
152
+ function sendBackgroundTaskControlResult(
153
+ deliver: TOutboundDeliver,
154
+ action: TBackgroundControlAction,
155
+ taskId: string,
156
+ operation: Promise<void>,
157
+ ): void {
158
+ operation.then(
159
+ () => deliver({ type: 'background_task_control_result', action, taskId, success: true }),
160
+ (error: Error) =>
161
+ deliver({
162
+ type: 'background_task_control_result',
163
+ action,
164
+ taskId,
165
+ success: false,
166
+ message: error.message,
167
+ }),
168
+ );
169
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Payload-agnostic channel frame codec (TRANS-001).
3
+ *
4
+ * The single place that knows the channel envelope. Pure and transport-neutral: a WebSocket binary
5
+ * frame, a WebRTC data-channel message, or any other byte carrier can use it unchanged. It NEVER
6
+ * interprets a binary body — that is the whole point of the payload-agnostic profile.
7
+ *
8
+ * Wire layout (all multi-byte integers big-endian):
9
+ *
10
+ * ```
11
+ * 0..2 magic 'R','B','F' ← lets a carrier route a frame without parsing it
12
+ * 3 version (1)
13
+ * 4 kind: 0x01 = opaque binary, 0x02 = structured event (UTF-8 JSON body)
14
+ * 5 channel-name length in bytes (1..255)
15
+ * 6.. channel name (UTF-8)
16
+ * +4 seq (uint32)
17
+ * ... body — opaque bytes (kind 0x01) or UTF-8 JSON `{ "event": string, "payload": … }` (0x02)
18
+ * ```
19
+ *
20
+ * The envelope deliberately carries NO length prefix for the body: the carrier already delimits the
21
+ * message (a WebSocket frame is a message boundary), so a length field would only be a second source
22
+ * of truth that can disagree with the first.
23
+ */
24
+
25
+ import type {
26
+ IBinaryFrame,
27
+ IChannelEventFrame,
28
+ TChannelReceiveResult,
29
+ } from '@robota-sdk/agent-interface-transport';
30
+
31
+ /**
32
+ * The JSON-serializable payload type an event frame carries. Read off the contract (rather than
33
+ * re-importing `agent-core`'s `TUniversalValue`) so this package keeps its single contract edge.
34
+ */
35
+ type TEventPayload = IChannelEventFrame['payload'];
36
+
37
+ const encoder = new TextEncoder();
38
+ const decoder = new TextDecoder();
39
+
40
+ /** `R`, `B`, `F` — the shared prefix every channel frame starts with. */
41
+ export const CHANNEL_FRAME_MAGIC: Uint8Array = encoder.encode('RBF');
42
+
43
+ /** Envelope version. Bumped only on an incompatible layout change. */
44
+ export const CHANNEL_FRAME_VERSION = 1;
45
+
46
+ const KIND_BINARY = 0x01;
47
+ const KIND_EVENT = 0x02;
48
+
49
+ /** Bytes before the channel name: magic(3) + version(1) + kind(1) + name length(1). */
50
+ const PREFIX_BYTES = 6;
51
+ /** Bytes after the channel name and before the body: seq(4). */
52
+ const SEQ_BYTES = 4;
53
+
54
+ const MAX_CHANNEL_NAME_BYTES = 255;
55
+ const MAX_SEQ = 0xffff_ffff;
56
+
57
+ /** Validate the envelope fields both encoders share, then return the encoded channel name. */
58
+ function encodeChannelName(channel: string, seq: number): Uint8Array {
59
+ const name = encoder.encode(channel);
60
+ if (name.byteLength === 0 || name.byteLength > MAX_CHANNEL_NAME_BYTES) {
61
+ throw new Error(
62
+ `channel name must be 1..${MAX_CHANNEL_NAME_BYTES} UTF-8 bytes (got ${name.byteLength})`,
63
+ );
64
+ }
65
+ if (!Number.isInteger(seq) || seq < 0 || seq > MAX_SEQ) {
66
+ throw new Error(`seq must be an integer in 0..${MAX_SEQ} (got ${seq})`);
67
+ }
68
+ return name;
69
+ }
70
+
71
+ /** Write the shared header into `out` and return the offset where the body starts. */
72
+ function writeHeader(out: Uint8Array, kind: number, name: Uint8Array, seq: number): number {
73
+ out.set(CHANNEL_FRAME_MAGIC, 0);
74
+ out[3] = CHANNEL_FRAME_VERSION;
75
+ out[4] = kind;
76
+ out[5] = name.byteLength;
77
+ out.set(name, PREFIX_BYTES);
78
+ const seqAt = PREFIX_BYTES + name.byteLength;
79
+ new DataView(out.buffer, out.byteOffset, out.byteLength).setUint32(seqAt, seq, false);
80
+ return seqAt + SEQ_BYTES;
81
+ }
82
+
83
+ /** Encode an opaque binary frame. The payload bytes are copied verbatim. */
84
+ export function encodeBinaryFrame(frame: IBinaryFrame): Uint8Array {
85
+ const name = encodeChannelName(frame.channel, frame.seq);
86
+ const out = new Uint8Array(PREFIX_BYTES + name.byteLength + SEQ_BYTES + frame.payload.byteLength);
87
+ const bodyAt = writeHeader(out, KIND_BINARY, name, frame.seq);
88
+ out.set(frame.payload, bodyAt);
89
+ return out;
90
+ }
91
+
92
+ /** Encode a structured, consumer-declared event frame (UTF-8 JSON body). */
93
+ export function encodeChannelEventFrame(frame: IChannelEventFrame): Uint8Array {
94
+ const name = encodeChannelName(frame.channel, frame.seq);
95
+ const body = encoder.encode(JSON.stringify({ event: frame.event, payload: frame.payload }));
96
+ const out = new Uint8Array(PREFIX_BYTES + name.byteLength + SEQ_BYTES + body.byteLength);
97
+ const bodyAt = writeHeader(out, KIND_EVENT, name, frame.seq);
98
+ out.set(body, bodyAt);
99
+ return out;
100
+ }
101
+
102
+ /** True when `bytes` starts with the channel-frame magic — a cheap, non-parsing routing check. */
103
+ export function isChannelFrame(bytes: Uint8Array): boolean {
104
+ if (bytes.byteLength < CHANNEL_FRAME_MAGIC.length) return false;
105
+ return CHANNEL_FRAME_MAGIC.every((b, i) => bytes[i] === b);
106
+ }
107
+
108
+ /** The decoded shape of an event frame's JSON body. */
109
+ interface IEventBody {
110
+ readonly event?: TEventPayload;
111
+ readonly payload?: TEventPayload;
112
+ }
113
+
114
+ /** Parse the JSON body of an event frame into its `event` + `payload` fields, or state why not. */
115
+ function parseEventBody(
116
+ body: Uint8Array,
117
+ ): { ok: true; event: string; payload: TEventPayload } | { ok: false; error: string } {
118
+ const text = decoder.decode(body);
119
+ let parsed: TEventPayload;
120
+ try {
121
+ parsed = JSON.parse(text) as TEventPayload;
122
+ } catch {
123
+ // An error RESULT, not a fallback: the caller reports it to the peer as a protocol error.
124
+ return { ok: false, error: 'channel frame: malformed JSON body' };
125
+ }
126
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
127
+ return { ok: false, error: 'channel frame: event body must be a JSON object' };
128
+ }
129
+ const { event, payload } = parsed as IEventBody;
130
+ if (typeof event !== 'string' || event.length === 0) {
131
+ return { ok: false, error: 'channel frame: event body is missing a non-empty `event` name' };
132
+ }
133
+ return { ok: true, event, payload: payload ?? null };
134
+ }
135
+
136
+ /**
137
+ * Decode a channel frame. Returns an explicit result union — a non-channel, truncated, or malformed
138
+ * input is a stated error the carrier reports to the peer, never a thrown exception on the hot path
139
+ * and never a silently dropped frame.
140
+ */
141
+ export function decodeChannelFrame(bytes: Uint8Array): TChannelReceiveResult {
142
+ if (!isChannelFrame(bytes)) {
143
+ return { ok: false, error: 'channel frame: bad magic (not a channel frame)' };
144
+ }
145
+ if (bytes.byteLength < PREFIX_BYTES) {
146
+ return { ok: false, error: 'channel frame: truncated header' };
147
+ }
148
+ if (bytes[3] !== CHANNEL_FRAME_VERSION) {
149
+ return { ok: false, error: `channel frame: unsupported version ${String(bytes[3])}` };
150
+ }
151
+
152
+ const kind = bytes[4];
153
+ const nameLength = bytes[5] ?? 0;
154
+ if (nameLength === 0) return { ok: false, error: 'channel frame: empty channel name' };
155
+
156
+ const seqAt = PREFIX_BYTES + nameLength;
157
+ const bodyAt = seqAt + SEQ_BYTES;
158
+ if (bytes.byteLength < bodyAt) {
159
+ return { ok: false, error: 'channel frame: truncated (header longer than the frame)' };
160
+ }
161
+
162
+ const channel = decoder.decode(bytes.subarray(PREFIX_BYTES, seqAt));
163
+ const seq = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(
164
+ seqAt,
165
+ false,
166
+ );
167
+ const body = bytes.subarray(bodyAt);
168
+
169
+ if (kind === KIND_BINARY) {
170
+ // Copy so the frame does not alias the carrier's (possibly pooled/reused) receive buffer.
171
+ return { ok: true, frame: { kind: 'binary', channel, seq, payload: Uint8Array.from(body) } };
172
+ }
173
+ if (kind === KIND_EVENT) {
174
+ const parsed = parseEventBody(body);
175
+ if (!parsed.ok) return { ok: false, error: parsed.error };
176
+ return {
177
+ ok: true,
178
+ frame: { kind: 'event', channel, seq, event: parsed.event, payload: parsed.payload },
179
+ };
180
+ }
181
+ return { ok: false, error: `channel frame: unknown kind ${String(kind)}` };
182
+ }
package/src/client.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** Browser-safe protocol surface: wire unions plus runtime frame decoders, with no Node admission code. */
2
+ export {
3
+ MAX_INBOUND_FRAME_BYTES,
4
+ decodeClientMessage,
5
+ decodeFrame,
6
+ decodeServerMessage,
7
+ } from './message-decoders.js';
8
+ export type { TMessageDecodeResult } from './message-decoders.js';
9
+ export type { TClientMessage, TServerMessage, TSeqServerMessage } from './wire-messages.js';
@@ -0,0 +1,235 @@
1
+ /**
2
+ * HANDOFF-001 (#1811): moving a session record that does not fit in one frame.
3
+ *
4
+ * A data channel has a maximum message size; a session record is a whole conversation. So the
5
+ * payload travels in pieces, and this owns the two halves of that: cutting it up, and putting it
6
+ * back together in the face of retries, reordering and a peer that says something inconsistent.
7
+ *
8
+ * ## Why the cut is made on BYTES rather than on the string
9
+ *
10
+ * The channel's limit is in bytes, and the serialized record is a JavaScript string — UTF-16 code
11
+ * units, where one character can be one byte or four. Slicing by characters and hoping the pieces
12
+ * fit means either measuring each slice's encoded length anyway, or shipping frames that overflow
13
+ * the channel for anyone whose conversation contains an emoji or a non-Latin script.
14
+ *
15
+ * Cutting the encoded bytes makes the budget exact. It costs base64's third: a byte slice is not a
16
+ * valid string on its own — it can end mid-character — so it cannot ride in a JSON frame as text.
17
+ * That cost is deliberate and is the smaller risk. The alternative works only because
18
+ * `JSON.stringify` has escaped lone surrogates since ES2019; a transfer whose correctness rests on
19
+ * that, and which fails as silent corruption when something in the chain does not, is not a trade
20
+ * worth a third of the bandwidth.
21
+ *
22
+ * ## What this does NOT decide
23
+ *
24
+ * It does not verify the digest. `verifyHandoffPayload` owns that, and asking the same question in
25
+ * two places is how the two answers drift. This reports that all the pieces arrived; whether they
26
+ * are the RIGHT pieces is the next check, and it deliberately runs on the whole reassembled payload
27
+ * rather than per chunk — a per-chunk digest would say each piece is intact while the set is not the
28
+ * set that was sent.
29
+ */
30
+
31
+ import type { IHandoffIntegrity } from '@robota-sdk/agent-interface-session-mobility';
32
+
33
+ /**
34
+ * One piece of a payload in flight.
35
+ *
36
+ * `total` travels on every chunk rather than in a separate header frame: a header can be the frame
37
+ * that gets lost, and a receiver that learned the count from a message it never got would wait
38
+ * forever for a number it cannot ask for again.
39
+ */
40
+ export interface IHandoffChunk {
41
+ readonly handoffId: string;
42
+ /** 0-based position in the sequence. */
43
+ readonly index: number;
44
+ /** How many chunks make up the whole payload. */
45
+ readonly total: number;
46
+ /** base64 of this slice of the UTF-8 encoded payload. */
47
+ readonly data: string;
48
+ }
49
+
50
+ /**
51
+ * Default frame budget, in bytes of encoded chunk data.
52
+ *
53
+ * 16 KiB because that is the size every SCTP implementation carries without negotiation. Larger
54
+ * works between two peers that both support it and fails between two that do not — and it fails at
55
+ * transfer time, on the user's session, not in a test.
56
+ */
57
+ export const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024;
58
+
59
+ /**
60
+ * Cut a sealed payload into chunks.
61
+ *
62
+ * Takes the serialized string the seal produced, not the record — the digest is over those exact
63
+ * bytes, and re-serializing here would let a chunked payload differ from the one the manifest
64
+ * describes.
65
+ */
66
+ export function chunkHandoffPayload(
67
+ handoffId: string,
68
+ serialized: string,
69
+ maxChunkBytes: number = DEFAULT_MAX_CHUNK_BYTES,
70
+ ): readonly IHandoffChunk[] {
71
+ if (!Number.isInteger(maxChunkBytes) || maxChunkBytes < 1) {
72
+ throw new Error(
73
+ `handoff chunking: maxChunkBytes must be a positive integer, got ${maxChunkBytes}. ` +
74
+ 'A zero or negative budget produces an infinite sequence of empty chunks, which presents ' +
75
+ 'as a hang rather than as the configuration error it is.',
76
+ );
77
+ }
78
+ const bytes = Buffer.from(serialized, 'utf8');
79
+ // An empty payload is still one chunk. Zero chunks would leave the receiver unable to tell a
80
+ // completed empty transfer from one that never started.
81
+ const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
82
+ const chunks: IHandoffChunk[] = [];
83
+ for (let index = 0; index < total; index += 1) {
84
+ const slice = bytes.subarray(index * maxChunkBytes, (index + 1) * maxChunkBytes);
85
+ chunks.push({ handoffId, index, total, data: slice.toString('base64') });
86
+ }
87
+ return chunks;
88
+ }
89
+
90
+ /** Why a chunk was not accepted. */
91
+ export type TChunkRejection =
92
+ /** Belongs to a different transfer. */
93
+ | 'wrong-handoff'
94
+ /** `index` is negative, or beyond the declared `total`. */
95
+ | 'out-of-range'
96
+ /** Its `total` disagrees with what earlier chunks declared. */
97
+ | 'inconsistent-total'
98
+ /** Its `data` is not decodable base64. */
99
+ | 'undecodable';
100
+
101
+ export type TChunkOutcome =
102
+ /** Stored; more are still needed. */
103
+ | 'accepted'
104
+ /** Already had this index, and it matched. A retry, not a problem. */
105
+ | 'duplicate'
106
+ /** Stored, and that was the last one missing. */
107
+ | 'complete'
108
+ | 'refused';
109
+
110
+ export interface IChunkResult {
111
+ readonly outcome: TChunkOutcome;
112
+ readonly rejection?: TChunkRejection;
113
+ /** Present when complete: the reassembled payload, ready for the integrity check. */
114
+ readonly serialized?: string;
115
+ /** How many distinct chunks are held. */
116
+ readonly received: number;
117
+ /** How many are expected, once anything has declared it. */
118
+ readonly expected?: number;
119
+ }
120
+
121
+ /**
122
+ * Collects the chunks of ONE transfer.
123
+ *
124
+ * One assembler per `handoffId`, because a shared one would have to key everything by transfer and
125
+ * would then be a place where two transfers can be confused for each other. A chunk naming a
126
+ * different transfer is refused rather than routed — routing is the caller's job and doing it here
127
+ * would make this a dispatcher as well as a buffer.
128
+ */
129
+ export class HandoffChunkAssembler {
130
+ private readonly chunks = new Map<number, Buffer>();
131
+ private declaredTotal: number | undefined;
132
+
133
+ constructor(private readonly handoffId: string) {}
134
+
135
+ get received(): number {
136
+ return this.chunks.size;
137
+ }
138
+
139
+ get expected(): number | undefined {
140
+ return this.declaredTotal;
141
+ }
142
+
143
+ /** Take one chunk. Reordering and retries are normal; inconsistency is not. */
144
+ accept(chunk: IHandoffChunk): IChunkResult {
145
+ const refuse = (rejection: TChunkRejection): IChunkResult => ({
146
+ outcome: 'refused',
147
+ rejection,
148
+ received: this.chunks.size,
149
+ ...(this.declaredTotal !== undefined ? { expected: this.declaredTotal } : {}),
150
+ });
151
+
152
+ if (chunk.handoffId !== this.handoffId) return refuse('wrong-handoff');
153
+ if (!Number.isInteger(chunk.total) || chunk.total < 1) return refuse('inconsistent-total');
154
+ if (this.declaredTotal !== undefined && chunk.total !== this.declaredTotal) {
155
+ // A peer that changes its mind about the size has either restarted the transfer under the
156
+ // same id or is not the peer we started with. Either way the pieces held so far can no longer
157
+ // be assumed to belong together.
158
+ return refuse('inconsistent-total');
159
+ }
160
+ if (!Number.isInteger(chunk.index) || chunk.index < 0 || chunk.index >= chunk.total) {
161
+ return refuse('out-of-range');
162
+ }
163
+
164
+ const decoded = Buffer.from(chunk.data, 'base64');
165
+ // Buffer.from is lenient — it drops what it cannot parse rather than throwing, so a mangled
166
+ // chunk would silently become a shorter one and corrupt the payload in a way only the final
167
+ // digest would catch, after the whole transfer completed. Re-encoding is how the leniency is
168
+ // detected here instead.
169
+ if (decoded.toString('base64') !== chunk.data) return refuse('undecodable');
170
+
171
+ this.declaredTotal = chunk.total;
172
+ const held = this.chunks.get(chunk.index);
173
+ if (held !== undefined) {
174
+ // A retry re-sending the same bytes is normal. Different bytes for the same index is the
175
+ // inconsistent case again, and the same reasoning applies.
176
+ if (held.equals(decoded)) {
177
+ return { outcome: 'duplicate', received: this.chunks.size, expected: chunk.total };
178
+ }
179
+ return refuse('inconsistent-total');
180
+ }
181
+
182
+ this.chunks.set(chunk.index, decoded);
183
+ if (this.chunks.size < chunk.total) {
184
+ return { outcome: 'accepted', received: this.chunks.size, expected: chunk.total };
185
+ }
186
+ return {
187
+ outcome: 'complete',
188
+ serialized: this.reassemble(chunk.total),
189
+ received: this.chunks.size,
190
+ expected: chunk.total,
191
+ };
192
+ }
193
+
194
+ /** Which indices are still missing, so a caller can ask for them rather than restarting. */
195
+ missing(): readonly number[] {
196
+ if (this.declaredTotal === undefined) return [];
197
+ const gaps: number[] = [];
198
+ for (let i = 0; i < this.declaredTotal; i += 1) if (!this.chunks.has(i)) gaps.push(i);
199
+ return gaps;
200
+ }
201
+
202
+ /** Drop everything held. The transfer was abandoned, or its integrity check failed. */
203
+ discard(): void {
204
+ this.chunks.clear();
205
+ this.declaredTotal = undefined;
206
+ }
207
+
208
+ private reassemble(total: number): string {
209
+ const ordered: Buffer[] = [];
210
+ for (let i = 0; i < total; i += 1) {
211
+ const part = this.chunks.get(i);
212
+ if (part === undefined) {
213
+ // Unreachable: size equalled total and every index was range-checked on the way in. Stated
214
+ // as an error rather than a non-null assertion, because the assertion would be the thing
215
+ // that hid it if the invariant ever broke.
216
+ throw new Error(`handoff chunking: chunk ${i} of ${total} is missing at reassembly.`);
217
+ }
218
+ ordered.push(part);
219
+ }
220
+ return Buffer.concat(ordered).toString('utf8');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * How many chunks a payload of this size will take, without building them.
226
+ *
227
+ * For a progress indicator at the sending end, and for a receiver deciding whether it is willing to
228
+ * hold that much before the first chunk arrives.
229
+ */
230
+ export function chunkCountFor(
231
+ integrity: IHandoffIntegrity,
232
+ maxChunkBytes: number = DEFAULT_MAX_CHUNK_BYTES,
233
+ ): number {
234
+ return Math.max(1, Math.ceil(integrity.byteLength / maxChunkBytes));
235
+ }