@abot-ai/socket 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +129 -2
  4. package/bin/abot-socket.mjs +15 -0
  5. package/config.example.json +14 -0
  6. package/docs/APP-REVIEW.md +184 -0
  7. package/docs/DEPLOYMENT.md +124 -0
  8. package/docs/INSTALLATION.md +177 -0
  9. package/docs/MIGRATION.md +84 -0
  10. package/docs/PROTOCOL.md +371 -0
  11. package/docs/SECURITY.md +116 -0
  12. package/docs/SELF-HOSTING.md +161 -0
  13. package/package.json +65 -4
  14. package/src/auth/auth-database.mjs +105 -0
  15. package/src/auth/auth-limits.mjs +17 -0
  16. package/src/auth/auth-pairing.mjs +95 -0
  17. package/src/auth/auth-sessions.mjs +119 -0
  18. package/src/auth/auth-storage-path.mjs +70 -0
  19. package/src/auth/auth.mjs +45 -0
  20. package/src/auth/runtime-credentials.mjs +39 -0
  21. package/src/cli/cli-arguments.mjs +23 -0
  22. package/src/cli/cli-entry.mjs +84 -0
  23. package/src/cli/cli-environment.mjs +31 -0
  24. package/src/cli/cli-help.mjs +34 -0
  25. package/src/cli/cli-output.mjs +35 -0
  26. package/src/cli/cli.mjs +58 -0
  27. package/src/cli/client-capabilities.mjs +14 -0
  28. package/src/cli/doctor.mjs +106 -0
  29. package/src/config/config-validation.mjs +41 -0
  30. package/src/config/config.mjs +57 -0
  31. package/src/deployment/deployment-command.mjs +42 -0
  32. package/src/deployment/deployment-input.mjs +76 -0
  33. package/src/deployment/deployment-plan.mjs +62 -0
  34. package/src/deployment/deployment-templates.mjs +109 -0
  35. package/src/main.mjs +26 -0
  36. package/src/notifications/notification-delivery-credentials.mjs +48 -0
  37. package/src/notifications/notification-delivery-fcm.mjs +100 -0
  38. package/src/notifications/notification-delivery-gateway.mjs +36 -0
  39. package/src/notifications/notification-delivery-http.mjs +53 -0
  40. package/src/notifications/notification-delivery-payload.mjs +45 -0
  41. package/src/notifications/notification-delivery.mjs +43 -0
  42. package/src/notifications/notification-gateway-command.mjs +48 -0
  43. package/src/notifications/notification-gateway-store.mjs +159 -0
  44. package/src/notifications/notification-gateway.mjs +181 -0
  45. package/src/notifications/notification-http.mjs +39 -0
  46. package/src/notifications/notification-input.mjs +82 -0
  47. package/src/notifications/notification-outbox.mjs +60 -0
  48. package/src/notifications/notification-preview-crypto.mjs +42 -0
  49. package/src/notifications/notification-preview-schema.mjs +7 -0
  50. package/src/notifications/notification-preview-store.mjs +63 -0
  51. package/src/notifications/notification-preview-text.mjs +20 -0
  52. package/src/notifications/notification-projection.mjs +20 -0
  53. package/src/notifications/notification-schema.mjs +22 -0
  54. package/src/notifications/notification-service.mjs +147 -0
  55. package/src/notifications/notification-store.mjs +132 -0
  56. package/src/pairing/pairing-qr-payload.mjs +32 -0
  57. package/src/pairing/pairing-qr.mjs +42 -0
  58. package/src/protocol/approval-protocol.mjs +39 -0
  59. package/src/protocol/protocol-validation.mjs +137 -0
  60. package/src/protocol/protocol.mjs +120 -0
  61. package/src/protocol/spark-chat-protocol.mjs +51 -0
  62. package/src/relay/relay-approvals.mjs +86 -0
  63. package/src/relay/relay-client-admission.mjs +40 -0
  64. package/src/relay/relay-correlations.mjs +101 -0
  65. package/src/relay/relay-model-catalog.mjs +69 -0
  66. package/src/relay/relay-request-adoption.mjs +97 -0
  67. package/src/relay/relay-requests.mjs +211 -0
  68. package/src/relay/relay-runtime-registration.mjs +49 -0
  69. package/src/relay/relay-session-mutations.mjs +122 -0
  70. package/src/relay/relay.mjs +222 -0
  71. package/src/relay/request-ownership-schema.mjs +18 -0
  72. package/src/relay/request-ownership-store.mjs +115 -0
  73. package/src/review/review-demo-isolation.mjs +132 -0
  74. package/src/review/review-demo-setup.mjs +97 -0
  75. package/src/review/review-portal-access.mjs +64 -0
  76. package/src/review/review-portal-form.mjs +24 -0
  77. package/src/review/review-portal-page.mjs +68 -0
  78. package/src/review/review-portal-server.mjs +105 -0
  79. package/src/setup/setup-files.mjs +37 -0
  80. package/src/setup/setup-questions.mjs +62 -0
  81. package/src/setup/setup-wizard.mjs +50 -0
  82. package/src/setup/setup.mjs +42 -0
  83. package/src/transport/http-api.mjs +32 -0
  84. package/src/transport/server.mjs +175 -0
  85. package/src/transport/transport-limits.mjs +85 -0
@@ -0,0 +1,97 @@
1
+ import { isRecord, relayError } from './relay-runtime-registration.mjs';
2
+
3
+ const REQUEST_STATUSES = new Set(['streaming', 'awaiting_approval', 'completed', 'failed']);
4
+ const requestKey = (runtime, requestId) => JSON.stringify([runtime.generation, requestId]);
5
+ const isTerminalRequest = request => ['completed', 'failed'].includes(request.status);
6
+
7
+ function confirmedSnapshotRequest(command, body) {
8
+ if (!body.ok) throw relayError('request_scope_unverified');
9
+ if (!Array.isArray(body.requests) || body.nextCursor !== null) throw relayError('request_scope_unverified');
10
+ const matches = body.requests.filter(request => isRecord(request) && request.requestId === command.requestId);
11
+ if (matches.length !== 1) throw relayError('request_scope_unverified');
12
+ const request = matches[0];
13
+ if (request.sessionId !== command.sessionId) throw relayError('request_scope_unverified');
14
+ if (!REQUEST_STATUSES.has(request.status)) throw relayError('request_scope_unverified');
15
+ return request;
16
+ }
17
+
18
+ /** First contact with existing computer work needs canonical proof, never a client ownership claim. */
19
+ export class RelayRequestAdoption {
20
+ constructor({ ownership, requests, correlations, transmit, connected, currentRuntime,
21
+ assertSessionAvailable, recover, error }) {
22
+ Object.assign(this, { ownership, requests, correlations, transmit, connected, currentRuntime,
23
+ assertSessionAvailable, recover, error });
24
+ this.pending = new Map();
25
+ }
26
+
27
+ assertNoPendingRequest(runtime, requestId) {
28
+ if (this.pending.has(requestKey(runtime, requestId))) throw relayError('approval_request_pending');
29
+ }
30
+
31
+ assertNoPendingSession(runtime, sessionId) {
32
+ for (const operation of this.pending.values()) {
33
+ if (operation.runtime === runtime && operation.command.sessionId === sessionId) {
34
+ throw relayError('session_busy');
35
+ }
36
+ }
37
+ }
38
+
39
+ assertCurrentPrincipal(client, runtime) {
40
+ if (!this.connected(client)) throw relayError('approval_access_unavailable');
41
+ if (!this.currentRuntime(runtime)) throw relayError('runtime_offline');
42
+ if (!client.allowedRuntimeIds.has(runtime.runtimeId)) throw relayError('runtime_forbidden');
43
+ if (this.ownership?.isOwnerSubject?.(client.subject) !== true) throw relayError('request_not_owned');
44
+ if (typeof this.ownership.adopt !== 'function') throw relayError('approval_access_unavailable');
45
+ }
46
+
47
+ assertCompatibleBinding(client, runtime, command) {
48
+ const binding = this.requests.bindings.get(JSON.stringify([runtime.runtimeId, command.requestId]));
49
+ if (!binding) return;
50
+ if (!binding.durable || binding.subject !== client.subject || binding.sessionId !== command.sessionId) {
51
+ throw relayError('request_not_owned');
52
+ }
53
+ }
54
+
55
+ beginIfUnowned(client, runtime, id, command) {
56
+ if (command.type !== 'request.approvals.request') return false;
57
+ if (!this.ownership || !runtime.features.includes('request_approvals_v1')) return false;
58
+ if (this.ownership.get(runtime.runtimeId, command.requestId)) return false;
59
+ this.assertCurrentPrincipal(client, runtime);
60
+ this.assertNoPendingRequest(runtime, command.requestId);
61
+ this.assertCompatibleBinding(client, runtime, command);
62
+ this.assertSessionAvailable(runtime, command.sessionId);
63
+ const operation = { client, runtime, id, command, key: requestKey(runtime, command.requestId) };
64
+ const snapshot = { type: 'session.snapshot.request', sessionId: command.sessionId, includeRequests: true };
65
+ const entry = this.correlations.create(client, runtime, id, snapshot, 'session.snapshot.response', {
66
+ respond: (_entry, body) => this.afterSnapshot(operation, body),
67
+ unknown: () => this.error(client, runtime, id, 'outcome_unknown'),
68
+ removed: () => {
69
+ if (this.pending.get(operation.key) === operation) this.pending.delete(operation.key);
70
+ },
71
+ });
72
+ this.pending.set(operation.key, operation);
73
+ if (!this.transmit(runtime.peer, { ...snapshot, correlationId: entry.correlationId })) {
74
+ this.correlations.fail(entry);
75
+ }
76
+ return true;
77
+ }
78
+
79
+ afterSnapshot(operation, body) {
80
+ const { client, runtime, id, command } = operation;
81
+ try {
82
+ this.assertCurrentPrincipal(client, runtime);
83
+ this.assertSessionAvailable(runtime, command.sessionId);
84
+ this.assertCompatibleBinding(client, runtime, command);
85
+ const canonical = confirmedSnapshotRequest(command, body);
86
+ const terminal = isTerminalRequest(canonical);
87
+ const binding = this.requests.bindings.get(JSON.stringify([runtime.runtimeId, command.requestId]));
88
+ this.requests.assertCapacity(binding, !terminal);
89
+ this.ownership.adopt({ subject: client.subject, deviceId: client.sessionId,
90
+ runtimeId: runtime.runtimeId, sessionId: command.sessionId, requestId: command.requestId }, terminal);
91
+ // Normal recovery binds before receiving approval events and retains the runtime's decision checks.
92
+ this.recover(client, runtime, id, command);
93
+ } catch (error) {
94
+ this.error(client, runtime, id, error.code ?? 'approval_access_unavailable');
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,211 @@
1
+ import { isRelayId, relayError } from './relay-runtime-registration.mjs';
2
+
3
+ const MAX_ACTIVE = 128;
4
+ const MAX_BINDINGS = 512;
5
+ const BINDING_TTL_MS = 12 * 60 * 60 * 1000;
6
+ const APPROVAL_TTL_MS = 5 * 60 * 1000;
7
+ const STREAM_TYPES = new Set(['token', 'event', 'completed', 'failed', 'session_message']);
8
+ const keyFor = (runtimeId, requestId) => JSON.stringify([runtimeId, requestId]);
9
+
10
+ function canConsumeObservedApproval(approval, approved, now) {
11
+ if (approval.state === 'expired') return approved === false;
12
+ if (approval.state !== 'pending') return false;
13
+ if (approval.expiresAt <= now) return approved === false;
14
+ return true;
15
+ }
16
+
17
+ export class RelayRequests {
18
+ constructor({ now, event, unknown, ownership, observe = () => {} }) {
19
+ this.now = now;
20
+ this.event = event;
21
+ this.unknown = unknown;
22
+ this.observe = observe;
23
+ this.ownership = ownership;
24
+ this.bindings = new Map();
25
+ }
26
+
27
+ create(client, runtime, command) {
28
+ const key = keyFor(runtime.runtimeId, command.requestId);
29
+ if (this.bindings.has(key) || this.ownership?.get(runtime.runtimeId, command.requestId)) {
30
+ throw relayError('request_id_conflict');
31
+ }
32
+ this.assertCapacity();
33
+ const durable = command.approvalMode === 'durable_v1';
34
+ if (durable) {
35
+ if (!this.ownership) throw relayError('approval_access_unavailable');
36
+ if (!runtime.features.includes('request_approvals_v1')) throw relayError('approvals_unsupported');
37
+ // This commit precedes transport: an uncertain send must never permit a replayed run.
38
+ this.ownership.create({ subject: client.subject, deviceId: client.sessionId,
39
+ runtimeId: runtime.runtimeId, sessionId: command.sessionId, requestId: command.requestId });
40
+ }
41
+ const record = { key, subject: client.subject, deviceId: client.sessionId, runtime, requestId: command.requestId,
42
+ sessionId: command.sessionId, state: 'active', subscribers: new Set([client]), durable,
43
+ expiresAt: durable ? Infinity : this.now() + BINDING_TTL_MS,
44
+ version: 0, approvals: new Map(), steering: new Set() };
45
+ this.bindings.set(key, record);
46
+ return record;
47
+ }
48
+
49
+ assertCapacity(existing, active = true) {
50
+ if (!existing && this.bindings.size >= MAX_BINDINGS) throw relayError('request_capacity');
51
+ const activeCount = [...this.bindings.values()].filter(record => record.state === 'active').length;
52
+ if (active && existing?.state !== 'active' && activeCount >= MAX_ACTIVE) {
53
+ throw relayError('active_request_capacity');
54
+ }
55
+ }
56
+
57
+ /** Restore a delivery view, not authority to execute or approve the saved request. */
58
+ restore(client, runtime, owner) {
59
+ const key = keyFor(runtime.runtimeId, owner.requestId);
60
+ const previous = this.bindings.get(key);
61
+ if (previous && (!previous.durable || previous.subject !== owner.subject
62
+ || previous.sessionId !== owner.sessionId)) throw relayError('request_not_owned');
63
+ const active = owner.status !== 'terminal';
64
+ const reusable = previous?.runtime === runtime && previous.state === (active ? 'active' : 'terminal');
65
+ this.assertCapacity(previous, active);
66
+ const record = reusable ? previous : { key, subject: owner.subject, deviceId: owner.deviceId,
67
+ runtime, sessionId: owner.sessionId, requestId: owner.requestId, durable: true,
68
+ state: active ? 'active' : 'terminal', expiresAt: active ? Infinity : owner.terminalAt + BINDING_TTL_MS,
69
+ version: 0, subscribers: new Set(previous?.subscribers), approvals: new Map(), steering: new Set() };
70
+ const wasSubscribed = record.subscribers.has(client);
71
+ const version = record.version;
72
+ record.subscribers.add(client);
73
+ this.bindings.set(key, record);
74
+ return { record, rollback: () => {
75
+ if (this.bindings.get(key) !== record || record.version !== version) return;
76
+ if (!reusable) {
77
+ if (!wasSubscribed) record.subscribers.delete(client);
78
+ if (previous) {
79
+ previous.subscribers = new Set(record.subscribers);
80
+ this.bindings.set(key, previous);
81
+ }
82
+ else this.bindings.delete(key);
83
+ } else if (!wasSubscribed) record.subscribers.delete(client);
84
+ } };
85
+ }
86
+
87
+ markTerminal(record) {
88
+ if (record.durable) this.ownership.markTerminal(record.runtime.runtimeId, record.requestId);
89
+ record.state = 'terminal';
90
+ record.expiresAt = this.now() + BINDING_TTL_MS;
91
+ record.version += 1;
92
+ this.invalidateApprovals(record);
93
+ }
94
+
95
+ owned(client, runtime, requestId, requireActive = false) {
96
+ const record = this.bindings.get(keyFor(runtime.runtimeId, requestId));
97
+ if (!record || record.subject !== client.subject) throw relayError('request_not_owned');
98
+ if (requireActive && (record.state !== 'active' || record.runtime !== runtime)) {
99
+ throw relayError('request_not_active');
100
+ }
101
+ return record;
102
+ }
103
+
104
+ subscribe(client, runtime, requestId) {
105
+ const record = this.owned(client, runtime, requestId);
106
+ record.subscribers.add(client);
107
+ return record;
108
+ }
109
+
110
+ reserveSteering(client, runtime, body) {
111
+ const record = this.owned(client, runtime, body.requestId, true);
112
+ if (!runtime.features.includes('request_steering_v1')) throw relayError('steering_unsupported');
113
+ if (record.steering.has(body.steerId)) throw relayError('duplicate_steer_id');
114
+ if (record.steering.size >= 128) throw relayError('steering_capacity');
115
+ record.steering.add(body.steerId);
116
+ }
117
+
118
+ consumeApproval(client, runtime, body) {
119
+ for (const record of this.bindings.values()) {
120
+ if (record.durable || record.runtime !== runtime || record.subject !== client.subject || record.state !== 'active') continue;
121
+ const approval = record.approvals.get(body.approvalId);
122
+ if (!approval) continue;
123
+ if (!canConsumeObservedApproval(approval, body.approved, this.now())) throw relayError('approval_not_pending');
124
+ // Consume before send: failure cannot make a possibly executed decision reusable.
125
+ approval.state = 'used';
126
+ return record;
127
+ }
128
+ throw relayError('approval_not_pending');
129
+ }
130
+
131
+ receive(runtime, body) {
132
+ if (!STREAM_TYPES.has(body.type) || !isRelayId(body.requestId)) return false;
133
+ const record = this.bindings.get(keyFor(runtime.runtimeId, body.requestId));
134
+ if (!record || record.runtime !== runtime || record.state !== 'active') return false;
135
+ if (!this.acceptSessionIdentity(record, body)) return false;
136
+ if (body.type === 'event' && (typeof body.name !== 'string' || body.name.length > 256)) return false;
137
+ if (!record.durable && body.type === 'event' && body.name === 'tool.approval.required') {
138
+ if (!this.observeApproval(record, body.approvalId)) return false;
139
+ }
140
+ if (body.type === 'event' && ['tool.approval.granted', 'tool.approval.rejected'].includes(body.name)) {
141
+ const approval = record.approvals.get(body.approvalId);
142
+ if (approval) approval.state = 'closed';
143
+ }
144
+ if (body.type === 'completed' || body.type === 'failed') {
145
+ this.markTerminal(record);
146
+ }
147
+ record.version += 1;
148
+ try {
149
+ this.observe({ deviceId: record.deviceId, runtimeId: runtime.runtimeId,
150
+ sessionId: record.sessionId, requestId: record.requestId }, body);
151
+ } catch { /* Notification storage must not interrupt the chat stream. */ }
152
+ for (const client of record.subscribers) this.event(client, runtime.runtimeId, body);
153
+ return true;
154
+ }
155
+
156
+ acceptSessionIdentity(record, body) {
157
+ if (record.durable) return body.sessionId === record.sessionId;
158
+ if (body.sessionId === undefined || body.sessionId === null) return true;
159
+ if (!isRelayId(body.sessionId)) return false;
160
+ if (record.sessionId && record.sessionId !== body.sessionId) return false;
161
+ record.sessionId = body.sessionId;
162
+ return true;
163
+ }
164
+
165
+ observeApproval(record, approvalId) {
166
+ if (typeof approvalId !== 'string' || !approvalId || approvalId.length > 512) return false;
167
+ const existing = record.approvals.get(approvalId);
168
+ if (existing) return existing.state === 'pending' && existing.expiresAt > this.now();
169
+ if (record.approvals.size >= 64) return false;
170
+ // Approval IDs may not be reused between simultaneous requests on one runtime.
171
+ for (const other of this.bindings.values()) {
172
+ if (other.runtime === record.runtime && other.approvals.has(approvalId)) return false;
173
+ }
174
+ record.approvals.set(approvalId, { state: 'pending', expiresAt: this.now() + APPROVAL_TTL_MS });
175
+ return true;
176
+ }
177
+
178
+ invalidateApprovals(record) {
179
+ for (const approval of record.approvals.values()) approval.state = 'closed';
180
+ }
181
+
182
+ markUnknown(record, notify = true) {
183
+ if (record.state !== 'active') return;
184
+ record.state = 'unknown';
185
+ record.expiresAt = record.durable ? Infinity : this.now() + BINDING_TTL_MS;
186
+ this.invalidateApprovals(record);
187
+ if (notify) for (const client of record.subscribers) this.unknown(client, record);
188
+ }
189
+
190
+ disconnectRuntime(runtime) {
191
+ for (const record of this.bindings.values()) if (record.runtime === runtime) this.markUnknown(record);
192
+ }
193
+
194
+ disconnectClient(client) {
195
+ for (const record of this.bindings.values()) record.subscribers.delete(client);
196
+ }
197
+
198
+ prune() {
199
+ for (const record of this.bindings.values()) {
200
+ for (const approval of record.approvals.values()) {
201
+ if (approval.expiresAt <= this.now() && approval.state === 'pending') approval.state = 'expired';
202
+ }
203
+ if (record.expiresAt > this.now()) continue;
204
+ if (record.state === 'active') {
205
+ this.markUnknown(record);
206
+ continue;
207
+ }
208
+ this.bindings.delete(record.key);
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,49 @@
1
+ import { parseRuntimeModelCatalog } from './relay-model-catalog.mjs';
2
+
3
+ const MODES = new Set(['ask', 'full_access', 'full_plus']);
4
+
5
+ export function isRecord(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+
9
+ export function isRelayId(value) {
10
+ return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
11
+ }
12
+
13
+ export function relayError(code) {
14
+ return Object.assign(new Error(code), { code });
15
+ }
16
+
17
+ export function isBoundedRuntimeFrame(body) {
18
+ if (!isRecord(body) || typeof body.type !== 'string' || body.type.length > 128) return false;
19
+ try {
20
+ return Buffer.byteLength(JSON.stringify(body)) <= 16 * 1024 * 1024;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function validateRuntimeGrant(grant) {
27
+ if (!isRecord(grant) || !isRelayId(grant.runtimeId)) throw relayError('invalid_runtime_grant');
28
+ const allowedModes = grant.allowedModes ?? ['ask'];
29
+ if (!Array.isArray(allowedModes) || allowedModes.length === 0 || allowedModes.length > 3) {
30
+ throw relayError('invalid_runtime_grant');
31
+ }
32
+ if (allowedModes.some(mode => !MODES.has(mode))) throw relayError('invalid_runtime_grant');
33
+ return { runtimeId: grant.runtimeId, allowedModes: [...new Set(allowedModes)] };
34
+ }
35
+
36
+ function boundedLabel(value, length) {
37
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= length;
38
+ }
39
+
40
+ export function parseRuntimeRegistration(body, runtimeId) {
41
+ if (body.type !== 'register' || body.agentId !== runtimeId) throw relayError('runtime_identity_mismatch');
42
+ if (Buffer.byteLength(JSON.stringify(body)) > 128 * 1024) throw relayError('registration_too_large');
43
+ const features = body.features ?? [];
44
+ if (!Array.isArray(features) || features.length > 64) throw relayError('invalid_runtime_features');
45
+ if (features.some(feature => !boundedLabel(feature, 128))) throw relayError('invalid_runtime_features');
46
+ if (new Set(features).size !== features.length) throw relayError('invalid_runtime_features');
47
+ const modelCatalog = body.modelCatalog === undefined ? null : parseRuntimeModelCatalog(body.modelCatalog);
48
+ return { features: [...features], modelCatalog };
49
+ }
@@ -0,0 +1,122 @@
1
+ import { expectedResponseType } from '../protocol/protocol.mjs';
2
+ import { relayError } from './relay-runtime-registration.mjs';
3
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
4
+
5
+ const MUTATIONS = new Set([
6
+ 'session.delete.request', 'session.messages.clear.request', 'session.message.delete.request',
7
+ ]);
8
+ const MUTATION_TIMEOUT_MS = 30_000;
9
+
10
+ export function isSessionMutation(type) {
11
+ return MUTATIONS.has(type);
12
+ }
13
+
14
+ const sessionKey = (runtime, sessionId) => JSON.stringify([runtime.generation, sessionId]);
15
+
16
+ /** A bounded read-before-delete guard, not an atomic lock over independent clients. */
17
+ export class RelaySessionMutations {
18
+ constructor({ now, correlations, requests, ownership, transmit, connected, error, respond }) {
19
+ Object.assign(this, { now, correlations, requests, ownership, transmit, connected, error, respond });
20
+ this.pending = new Map();
21
+ }
22
+
23
+ assertNoPending(runtime, sessionId) {
24
+ if (this.pending.has(sessionKey(runtime, sessionId))) throw relayError('session_mutation_pending');
25
+ }
26
+
27
+ assertNoObservedWork(runtime, sessionId) {
28
+ for (const record of this.requests.bindings.values()) {
29
+ if (record.sessionId !== sessionId) continue;
30
+ if (record.durable) {
31
+ if (record.runtime.runtimeId !== runtime.runtimeId) continue;
32
+ // Unknown durable work needs individual canonical proof below; it is
33
+ // never treated as completed merely because this relay restarted.
34
+ if (record.state === 'unknown') continue;
35
+ } else if (record.runtime !== runtime) continue;
36
+ if (record.state !== 'terminal') throw relayError('session_busy');
37
+ }
38
+ for (const entry of this.correlations.pending.values()) {
39
+ if (entry.runtime === runtime && entry.command.sessionId === sessionId
40
+ && (entry.command.type === 'attachment.upload.request' || isApprovalCommand(entry.command.type))) {
41
+ throw relayError('session_busy');
42
+ }
43
+ }
44
+ }
45
+
46
+ dispatch(client, runtime, id, command) {
47
+ this.assertNoPending(runtime, command.sessionId);
48
+ this.assertNoObservedWork(runtime, command.sessionId);
49
+ const operation = { client, runtime, id, command,
50
+ expiresAt: this.now() + MUTATION_TIMEOUT_MS, key: sessionKey(runtime, command.sessionId) };
51
+ this.snapshot(operation);
52
+ }
53
+
54
+ snapshot(operation) {
55
+ const command = { type: 'session.snapshot.request', sessionId: operation.command.sessionId,
56
+ includeRequests: true };
57
+ this.send(operation, command, body => this.afterSnapshot(operation, body));
58
+ }
59
+
60
+ afterSnapshot(operation, body) {
61
+ if (!this.connected(operation.client)) return;
62
+ try {
63
+ if (this.now() >= operation.expiresAt) throw relayError('session_state_unavailable');
64
+ this.assertNoObservedWork(operation.runtime, operation.command.sessionId);
65
+ if (!body.ok) throw relayError(body.error === 'session_not_found' ? 'session_not_found' : 'session_state_unavailable');
66
+ // The existing bridge returns every request and no continuation cursor.
67
+ // A partial/newer contract cannot prove idleness; never guess pagination.
68
+ if (!Array.isArray(body.requests) || body.nextCursor !== null) throw relayError('session_state_unavailable');
69
+ for (const request of body.requests) {
70
+ if (!request || typeof request !== 'object') throw relayError('session_state_unavailable');
71
+ if (request.sessionId !== operation.command.sessionId) throw relayError('session_state_unavailable');
72
+ if (!['completed', 'failed'].includes(request.status)) throw relayError('session_busy');
73
+ }
74
+ this.reconcileOwnership(operation, body.requests);
75
+ this.send(operation, operation.command, result => {
76
+ const wholeSessionRemoved = result.ok && (
77
+ (operation.command.type === 'session.delete.request' && result.deleted === true)
78
+ || (operation.command.type === 'session.messages.clear.request' && result.cleared === true));
79
+ if (wholeSessionRemoved) this.ownership?.retireSession(operation.runtime.runtimeId, operation.command.sessionId);
80
+ this.respond(operation, result);
81
+ });
82
+ } catch (error) {
83
+ this.error(operation, error.code ?? 'session_state_unavailable');
84
+ }
85
+ }
86
+
87
+ reconcileOwnership(operation, canonical) {
88
+ const runtimeId = operation.runtime.runtimeId;
89
+ const owners = this.ownership?.listSession(runtimeId, operation.command.sessionId) ?? [];
90
+ const open = owners.filter(owner => owner.status === 'open');
91
+ // Missing requests and partial histories are not evidence of completion.
92
+ for (const owner of open) {
93
+ if (!canonical.some(request => request.requestId === owner.requestId
94
+ && request.sessionId === owner.sessionId && ['completed', 'failed'].includes(request.status))) {
95
+ throw relayError('session_busy');
96
+ }
97
+ }
98
+ for (const owner of open) {
99
+ this.ownership.markTerminal(runtimeId, owner.requestId);
100
+ const view = this.requests.bindings.get(JSON.stringify([runtimeId, owner.requestId]));
101
+ if (view?.durable && view.sessionId === owner.sessionId) this.requests.markTerminal(view);
102
+ }
103
+ }
104
+
105
+ send(operation, command, respond) {
106
+ const entry = this.correlations.create(operation.client, operation.runtime, operation.id,
107
+ command, expectedResponseType(command.type), {
108
+ respond: (_entry, body) => respond(body),
109
+ unknown: () => this.error(operation, 'outcome_unknown'),
110
+ removed: () => {
111
+ if (this.pending.get(operation.key) === operation) this.pending.delete(operation.key);
112
+ },
113
+ // Keep the lock while an already sent mutation may still be running.
114
+ retainOnClientDisconnect: true,
115
+ });
116
+ entry.expiresAt = operation.expiresAt;
117
+ this.pending.set(operation.key, operation);
118
+ if (!this.transmit(operation.runtime.peer, { ...command, correlationId: entry.correlationId })) {
119
+ this.correlations.fail(entry);
120
+ }
121
+ }
122
+ }