@abot-ai/socket 0.1.0 → 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.
@@ -0,0 +1,86 @@
1
+ import { expectedResponseType } from '../protocol/protocol.mjs';
2
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
3
+ import { isRecord, isRelayId, relayError } from './relay-runtime-registration.mjs';
4
+ import { RelayRequestAdoption } from './relay-request-adoption.mjs';
5
+
6
+ function validWait(wait) {
7
+ return isRecord(wait) && isRelayId(wait.generation) && isRelayId(wait.waitId)
8
+ && Number.isSafeInteger(wait.revision) && wait.revision >= 0;
9
+ }
10
+
11
+ function validResponse(command, body) {
12
+ if (!body.ok) return typeof body.error === 'string' && body.error.length <= 256;
13
+ if (command.type === 'request.approval.decide.request') {
14
+ return isRecord(body.result) && typeof body.result.accepted === 'boolean'
15
+ && (body.result.duplicate === undefined || typeof body.result.duplicate === 'boolean')
16
+ && (body.result.reason === undefined || typeof body.result.reason === 'string');
17
+ }
18
+ if (!Array.isArray(body.approvals) || body.approvals.length > 64) return false;
19
+ const seen = new Set();
20
+ for (const approval of body.approvals) {
21
+ const request = approval?.request;
22
+ if (!isRecord(approval) || !isRecord(request) || request.requestId !== command.requestId) return false;
23
+ if (typeof request.approvalId !== 'string' || !request.approvalId || request.approvalId.length > 512) return false;
24
+ if (seen.has(request.approvalId)) return false;
25
+ seen.add(request.approvalId);
26
+ if (approval.wait !== undefined && !validWait(approval.wait)) return false;
27
+ }
28
+ if (body.lifecycle !== null && !isRecord(body.lifecycle)) return false;
29
+ if (body.lifecycle?.sessionId !== undefined && body.lifecycle.sessionId !== command.sessionId) return false;
30
+ if (body.lifecycle?.requestId !== undefined && body.lifecycle.requestId !== command.requestId) return false;
31
+ if (body.finalState === null) return true;
32
+ return isRecord(body.finalState) && ['completed', 'failed'].includes(body.finalState.status)
33
+ && body.approvals.length === 0;
34
+ }
35
+
36
+ /** An owner-scoped transport view. The runtime alone stores and decides approvals. */
37
+ export class RelayApprovals {
38
+ constructor({ ownership, requests, correlations, transmit, respond, error,
39
+ connected, currentRuntime, assertSessionAvailable }) {
40
+ Object.assign(this, { ownership, requests, correlations, transmit, respond, error });
41
+ this.adoption = new RelayRequestAdoption({ ownership, requests, correlations, transmit, error,
42
+ connected, currentRuntime, assertSessionAvailable,
43
+ recover: (client, runtime, id, command) => this.dispatch(client, runtime, id, command) });
44
+ }
45
+
46
+ owned(client, runtime, command) {
47
+ if (!this.ownership) throw relayError('approval_access_unavailable');
48
+ if (!runtime.features.includes('request_approvals_v1')) throw relayError('approvals_unsupported');
49
+ const owner = this.ownership.get(runtime.runtimeId, command.requestId);
50
+ if (!owner || owner.subject !== client.subject || owner.sessionId !== command.sessionId) {
51
+ throw relayError('request_not_owned');
52
+ }
53
+ return owner;
54
+ }
55
+
56
+ dispatch(client, runtime, id, command) {
57
+ if (this.adoption.beginIfUnowned(client, runtime, id, command)) return;
58
+ this.adoption.assertNoPendingRequest(runtime, command.requestId);
59
+ const owner = this.owned(client, runtime, command);
60
+ for (const entry of this.correlations.pending.values()) {
61
+ if (entry.runtime === runtime && entry.command.requestId === command.requestId
62
+ && isApprovalCommand(entry.command.type)) throw relayError('approval_request_pending');
63
+ }
64
+ let view;
65
+ const entry = this.correlations.create(client, runtime, id, command, expectedResponseType(command.type), {
66
+ matches: body => validResponse(command, body),
67
+ respond: (_entry, body) => {
68
+ if (!body.ok || body.result?.accepted === false) view.rollback();
69
+ else if (body.finalState) this.requests.markTerminal(view.record);
70
+ this.respond(client, runtime, id, body);
71
+ },
72
+ unknown: () => {
73
+ view.rollback();
74
+ this.error(client, runtime, id, 'outcome_unknown');
75
+ },
76
+ // A sent decision can still take effect after its client closes. Keep the
77
+ // mutation guard until its reply/deadline, but disconnect removes the view.
78
+ retainOnClientDisconnect: true,
79
+ });
80
+ try { view = this.requests.restore(client, runtime, owner); }
81
+ catch (error) { this.correlations.remove(entry); throw error; }
82
+ if (!this.transmit(runtime.peer, { ...command, correlationId: entry.correlationId })) {
83
+ this.correlations.fail(entry);
84
+ }
85
+ }
86
+ }
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isRecord, relayError } from './relay-runtime-registration.mjs';
3
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
4
+ import { SPARK_OPEN_REQUEST, projectSparkOpenResponse } from '../protocol/spark-chat-protocol.mjs';
3
5
 
4
6
  const MAX_PENDING = 128;
5
7
  const RPC_TIMEOUT_MS = 30_000;
@@ -10,12 +12,14 @@ function steeringKey(runtime, body) {
10
12
 
11
13
  function matchesResponseIdentity(entry, body) {
12
14
  const command = entry.command;
15
+ if (command.type === SPARK_OPEN_REQUEST) return projectSparkOpenResponse(body) !== null;
13
16
  if (command.type === 'steer_request') {
14
17
  return body.requestId === command.requestId && body.steerId === command.steerId
15
18
  && typeof body.accepted === 'boolean';
16
19
  }
17
20
  if (typeof body.ok !== 'boolean') return false;
18
- if (command.type === 'request.events.request' && body.requestId !== command.requestId) return false;
21
+ if ((command.type === 'request.events.request' || isApprovalCommand(command.type))
22
+ && body.requestId !== command.requestId) return false;
19
23
  // Upload responses intentionally do not echo requestId in the existing runtime.
20
24
  if (body.requestId !== undefined && body.requestId !== command.requestId) return false;
21
25
  if (command.sessionId !== undefined && body.sessionId !== command.sessionId) return false;
@@ -28,7 +32,7 @@ function matchesResponseIdentity(entry, body) {
28
32
  if (!Array.isArray(body.events) || body.events.length > 8192) return false;
29
33
  if (body.events.some(event => !isRecord(event) || (event.requestId !== undefined && event.requestId !== command.requestId))) return false;
30
34
  }
31
- return true;
35
+ return entry.lifecycle.matches?.(body) ?? true;
32
36
  }
33
37
 
34
38
  export class RelayCorrelations {
@@ -69,7 +73,8 @@ export class RelayCorrelations {
69
73
  if (entry.expiresAt <= this.now() || !matchesResponseIdentity(entry, body)) return false;
70
74
  // An invalid reply must never consume the genuine request's pending record.
71
75
  this.remove(entry);
72
- (entry.lifecycle.respond ?? this.respond)(entry, body);
76
+ const response = entry.command.type === SPARK_OPEN_REQUEST ? projectSparkOpenResponse(body) : body;
77
+ (entry.lifecycle.respond ?? this.respond)(entry, response);
73
78
  return true;
74
79
  }
75
80
 
@@ -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
+ }
@@ -15,27 +15,83 @@ function canConsumeObservedApproval(approval, approved, now) {
15
15
  }
16
16
 
17
17
  export class RelayRequests {
18
- constructor({ now, event, unknown, observe = () => {} }) {
18
+ constructor({ now, event, unknown, ownership, observe = () => {} }) {
19
19
  this.now = now;
20
20
  this.event = event;
21
21
  this.unknown = unknown;
22
22
  this.observe = observe;
23
+ this.ownership = ownership;
23
24
  this.bindings = new Map();
24
25
  }
25
26
 
26
27
  create(client, runtime, command) {
27
28
  const key = keyFor(runtime.runtimeId, command.requestId);
28
- if (this.bindings.has(key)) throw relayError('request_id_conflict');
29
- if (this.bindings.size >= MAX_BINDINGS) throw relayError('request_capacity');
30
- const activeCount = [...this.bindings.values()].filter(record => record.state === 'active').length;
31
- if (activeCount >= MAX_ACTIVE) throw relayError('active_request_capacity');
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
+ }
32
41
  const record = { key, subject: client.subject, deviceId: client.sessionId, runtime, requestId: command.requestId,
33
- sessionId: command.sessionId, state: 'active', subscribers: new Set([client]),
34
- expiresAt: this.now() + BINDING_TTL_MS, approvals: new Map(), steering: new Set() };
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() };
35
45
  this.bindings.set(key, record);
36
46
  return record;
37
47
  }
38
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
+
39
95
  owned(client, runtime, requestId, requireActive = false) {
40
96
  const record = this.bindings.get(keyFor(runtime.runtimeId, requestId));
41
97
  if (!record || record.subject !== client.subject) throw relayError('request_not_owned');
@@ -61,7 +117,7 @@ export class RelayRequests {
61
117
 
62
118
  consumeApproval(client, runtime, body) {
63
119
  for (const record of this.bindings.values()) {
64
- if (record.runtime !== runtime || record.subject !== client.subject || record.state !== 'active') continue;
120
+ if (record.durable || record.runtime !== runtime || record.subject !== client.subject || record.state !== 'active') continue;
65
121
  const approval = record.approvals.get(body.approvalId);
66
122
  if (!approval) continue;
67
123
  if (!canConsumeObservedApproval(approval, body.approved, this.now())) throw relayError('approval_not_pending');
@@ -78,7 +134,7 @@ export class RelayRequests {
78
134
  if (!record || record.runtime !== runtime || record.state !== 'active') return false;
79
135
  if (!this.acceptSessionIdentity(record, body)) return false;
80
136
  if (body.type === 'event' && (typeof body.name !== 'string' || body.name.length > 256)) return false;
81
- if (body.type === 'event' && body.name === 'tool.approval.required') {
137
+ if (!record.durable && body.type === 'event' && body.name === 'tool.approval.required') {
82
138
  if (!this.observeApproval(record, body.approvalId)) return false;
83
139
  }
84
140
  if (body.type === 'event' && ['tool.approval.granted', 'tool.approval.rejected'].includes(body.name)) {
@@ -86,10 +142,9 @@ export class RelayRequests {
86
142
  if (approval) approval.state = 'closed';
87
143
  }
88
144
  if (body.type === 'completed' || body.type === 'failed') {
89
- record.state = 'terminal';
90
- record.expiresAt = this.now() + BINDING_TTL_MS;
91
- this.invalidateApprovals(record);
145
+ this.markTerminal(record);
92
146
  }
147
+ record.version += 1;
93
148
  try {
94
149
  this.observe({ deviceId: record.deviceId, runtimeId: runtime.runtimeId,
95
150
  sessionId: record.sessionId, requestId: record.requestId }, body);
@@ -99,6 +154,7 @@ export class RelayRequests {
99
154
  }
100
155
 
101
156
  acceptSessionIdentity(record, body) {
157
+ if (record.durable) return body.sessionId === record.sessionId;
102
158
  if (body.sessionId === undefined || body.sessionId === null) return true;
103
159
  if (!isRelayId(body.sessionId)) return false;
104
160
  if (record.sessionId && record.sessionId !== body.sessionId) return false;
@@ -126,7 +182,7 @@ export class RelayRequests {
126
182
  markUnknown(record, notify = true) {
127
183
  if (record.state !== 'active') return;
128
184
  record.state = 'unknown';
129
- record.expiresAt = this.now() + BINDING_TTL_MS;
185
+ record.expiresAt = record.durable ? Infinity : this.now() + BINDING_TTL_MS;
130
186
  this.invalidateApprovals(record);
131
187
  if (notify) for (const client of record.subscribers) this.unknown(client, record);
132
188
  }
@@ -1,5 +1,6 @@
1
1
  import { expectedResponseType } from '../protocol/protocol.mjs';
2
2
  import { relayError } from './relay-runtime-registration.mjs';
3
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
3
4
 
4
5
  const MUTATIONS = new Set([
5
6
  'session.delete.request', 'session.messages.clear.request', 'session.message.delete.request',
@@ -14,8 +15,8 @@ const sessionKey = (runtime, sessionId) => JSON.stringify([runtime.generation, s
14
15
 
15
16
  /** A bounded read-before-delete guard, not an atomic lock over independent clients. */
16
17
  export class RelaySessionMutations {
17
- constructor({ now, correlations, requests, transmit, connected, error, respond }) {
18
- Object.assign(this, { now, correlations, requests, transmit, connected, error, respond });
18
+ constructor({ now, correlations, requests, ownership, transmit, connected, error, respond }) {
19
+ Object.assign(this, { now, correlations, requests, ownership, transmit, connected, error, respond });
19
20
  this.pending = new Map();
20
21
  }
21
22
 
@@ -25,12 +26,20 @@ export class RelaySessionMutations {
25
26
 
26
27
  assertNoObservedWork(runtime, sessionId) {
27
28
  for (const record of this.requests.bindings.values()) {
28
- if (record.runtime !== runtime || record.sessionId !== sessionId) continue;
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;
29
36
  if (record.state !== 'terminal') throw relayError('session_busy');
30
37
  }
31
38
  for (const entry of this.correlations.pending.values()) {
32
39
  if (entry.runtime === runtime && entry.command.sessionId === sessionId
33
- && entry.command.type === 'attachment.upload.request') throw relayError('session_busy');
40
+ && (entry.command.type === 'attachment.upload.request' || isApprovalCommand(entry.command.type))) {
41
+ throw relayError('session_busy');
42
+ }
34
43
  }
35
44
  }
36
45
 
@@ -62,12 +71,37 @@ export class RelaySessionMutations {
62
71
  if (request.sessionId !== operation.command.sessionId) throw relayError('session_state_unavailable');
63
72
  if (!['completed', 'failed'].includes(request.status)) throw relayError('session_busy');
64
73
  }
65
- this.send(operation, operation.command, result => this.respond(operation, result));
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
+ });
66
82
  } catch (error) {
67
83
  this.error(operation, error.code ?? 'session_state_unavailable');
68
84
  }
69
85
  }
70
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
+
71
105
  send(operation, command, respond) {
72
106
  const entry = this.correlations.create(operation.client, operation.runtime, operation.id,
73
107
  command, expectedResponseType(command.type), {
@@ -3,19 +3,23 @@ import { normalizeClientCommand, expectedResponseType } from '../protocol/protoc
3
3
  import { createRelayClient, admitRelayCommand } from './relay-client-admission.mjs';
4
4
  import { RelayCorrelations } from './relay-correlations.mjs';
5
5
  import { RelayRequests } from './relay-requests.mjs';
6
+ import { RelayApprovals } from './relay-approvals.mjs';
7
+ import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
8
+ import { assertSparkChatSupported } from '../protocol/spark-chat-protocol.mjs';
6
9
  import { RelaySessionMutations, isSessionMutation } from './relay-session-mutations.mjs';
7
10
  import { isBoundedRuntimeFrame, isRelayId, parseRuntimeRegistration,
8
11
  validateRuntimeGrant, relayError } from './relay-runtime-registration.mjs';
9
12
 
10
13
  export class Relay {
11
- constructor({ now = Date.now, send, close, observeRequest }) {
14
+ constructor({ now = Date.now, send, close, observeRequest, ownership, isClientAuthorized = () => true }) {
12
15
  this.now = now;
13
16
  this.send = send;
14
17
  this.close = close;
18
+ this.ownership = ownership;
15
19
  this.clients = new Map();
16
20
  this.runtimes = new Map();
17
21
  this.runtimePeers = new Map();
18
- this.requests = new RelayRequests({ now, observe: observeRequest,
22
+ this.requests = new RelayRequests({ now, ownership, observe: observeRequest,
19
23
  event: (client, runtimeId, body) => this.deliver(client, { type: 'relay.event', runtimeId, body }),
20
24
  unknown: (client, record) => this.error(client.peer, undefined, record.runtime.runtimeId,
21
25
  'outcome_unknown', { requestId: record.requestId }),
@@ -25,8 +29,17 @@ export class Relay {
25
29
  runtimeId: entry.runtime.runtimeId, body }),
26
30
  unknown: entry => this.error(entry.client.peer, entry.id, entry.runtime.runtimeId, 'outcome_unknown'),
27
31
  });
32
+ this.approvals = new RelayApprovals({ ownership, requests: this.requests, correlations: this.correlations,
33
+ transmit: (peer, body) => this.transmit(peer, body),
34
+ connected: client => this.clients.get(client.peer) === client && isClientAuthorized(client),
35
+ currentRuntime: runtime => this.runtimes.get(runtime.runtimeId) === runtime && runtime.registered,
36
+ assertSessionAvailable: (runtime, sessionId) => this.sessionMutations.assertNoPending(runtime, sessionId),
37
+ respond: (client, runtime, id, body) => this.deliver(client, { type: 'relay.response', id,
38
+ runtimeId: runtime.runtimeId, body }),
39
+ error: (client, runtime, id, code) => this.error(client.peer, id, runtime.runtimeId, code),
40
+ });
28
41
  this.sessionMutations = new RelaySessionMutations({ now, correlations: this.correlations,
29
- requests: this.requests, transmit: (peer, body) => this.transmit(peer, body),
42
+ requests: this.requests, ownership, transmit: (peer, body) => this.transmit(peer, body),
30
43
  connected: client => this.clients.get(client.peer) === client,
31
44
  error: (operation, code) => this.error(operation.client.peer, operation.id, operation.runtime.runtimeId, code),
32
45
  respond: (operation, body) => this.deliver(operation.client, { type: 'relay.response',
@@ -136,11 +149,16 @@ export class Relay {
136
149
  }
137
150
 
138
151
  dispatch(client, runtime, id, command) {
139
- if (isSessionMutation(command.type)) return this.sessionMutations.dispatch(client, runtime, id, command);
140
- if (['run_request', 'attachment.upload.request'].includes(command.type)) {
152
+ assertSparkChatSupported(command, runtime);
153
+ if (isSessionMutation(command.type)) {
154
+ this.approvals.adoption.assertNoPendingSession(runtime, command.sessionId);
155
+ return this.sessionMutations.dispatch(client, runtime, id, command);
156
+ }
157
+ if (['run_request', 'attachment.upload.request'].includes(command.type) || isApprovalCommand(command.type)) {
141
158
  this.sessionMutations.assertNoPending(runtime, command.sessionId);
142
159
  }
143
160
  if (command.type === 'run_request') return this.dispatchRun(client, runtime, id, command);
161
+ if (isApprovalCommand(command.type)) return this.approvals.dispatch(client, runtime, id, command);
144
162
  if (command.type === 'subscribe_request') {
145
163
  this.requests.subscribe(client, runtime, command.requestId);
146
164
  return this.acknowledge(client, runtime, id, { requestId: command.requestId, status: 'subscribed' });
@@ -155,6 +173,7 @@ export class Relay {
155
173
  }
156
174
 
157
175
  dispatchRun(client, runtime, id, command) {
176
+ this.approvals.adoption.assertNoPendingRequest(runtime, command.requestId);
158
177
  const record = this.requests.create(client, runtime, command);
159
178
  if (!this.transmit(runtime.peer, command)) {
160
179
  this.requests.markUnknown(record, false);
@@ -198,5 +217,6 @@ export class Relay {
198
217
  }
199
218
  this.correlations.prune();
200
219
  this.requests.prune();
220
+ this.ownership?.prune();
201
221
  }
202
222
  }
@@ -0,0 +1,18 @@
1
+ /** Routing identity only. Approval authority and execution state belong to the runtime. */
2
+ export const REQUEST_OWNERSHIP_SCHEMA = `
3
+ CREATE TABLE request_ownership (
4
+ runtime_id TEXT NOT NULL CHECK(length(runtime_id) BETWEEN 1 AND 128),
5
+ request_id TEXT NOT NULL CHECK(length(request_id) BETWEEN 1 AND 128),
6
+ session_id TEXT NOT NULL CHECK(length(session_id) BETWEEN 1 AND 128),
7
+ subject TEXT NOT NULL CHECK(length(subject) BETWEEN 1 AND 320),
8
+ device_id TEXT NOT NULL CHECK(length(device_id) BETWEEN 1 AND 256),
9
+ status TEXT NOT NULL CHECK(status IN ('open', 'terminal')),
10
+ created_at INTEGER NOT NULL CHECK(created_at >= 0),
11
+ terminal_at INTEGER,
12
+ PRIMARY KEY(runtime_id, request_id),
13
+ CHECK((status = 'open' AND terminal_at IS NULL)
14
+ OR (status = 'terminal' AND terminal_at IS NOT NULL AND terminal_at >= 0))
15
+ ) STRICT;
16
+ CREATE INDEX request_ownership_session ON request_ownership(runtime_id, session_id);
17
+ CREATE INDEX request_ownership_terminal ON request_ownership(terminal_at) WHERE status = 'terminal';
18
+ `;
@@ -0,0 +1,115 @@
1
+ import { isRelayId, relayError } from './relay-runtime-registration.mjs';
2
+
3
+ const MAX_OWNERSHIP_RECORDS = 512;
4
+ const TERMINAL_RETENTION_MS = 12 * 60 * 60 * 1000;
5
+ const SELECT_RECORD = `SELECT runtime_id AS runtimeId, session_id AS sessionId,
6
+ request_id AS requestId, subject, device_id AS deviceId, status,
7
+ created_at AS createdAt, terminal_at AS terminalAt FROM request_ownership`;
8
+
9
+ function assertRequestIdentity(runtimeId, requestId) {
10
+ if (!isRelayId(runtimeId)) throw relayError('invalid_request_identity');
11
+ if (!isRelayId(requestId)) throw relayError('invalid_request_identity');
12
+ }
13
+
14
+ function assertOwnershipIdentity(identity, ownerId) {
15
+ if (!identity || typeof identity !== 'object') throw relayError('invalid_request_identity');
16
+ assertRequestIdentity(identity.runtimeId, identity.requestId);
17
+ if (!isRelayId(identity.sessionId)) throw relayError('invalid_request_identity');
18
+ if (identity.subject !== ownerId) throw relayError('invalid_request_identity');
19
+ if (typeof identity.deviceId !== 'string') throw relayError('invalid_request_identity');
20
+ if (!identity.deviceId.trim() || identity.deviceId.length > 256) throw relayError('invalid_request_identity');
21
+ }
22
+
23
+ /** Durable owner binding, never an approval cache or a queue for replaying runs. */
24
+ export class RequestOwnershipStore {
25
+ constructor({ database, now = Date.now }) {
26
+ if (typeof database?.prepare !== 'function' || typeof database?.transaction !== 'function') {
27
+ throw new TypeError('An authentication database is required');
28
+ }
29
+ if (typeof now !== 'function') throw new TypeError('An ownership clock is required');
30
+ this.database = database;
31
+ this.now = now;
32
+ }
33
+
34
+ create(identity) {
35
+ assertOwnershipIdentity(identity, this.database.ownerId);
36
+ const time = this.#time();
37
+ return this.database.transaction(() => {
38
+ if (this.get(identity.runtimeId, identity.requestId)) throw relayError('request_id_conflict');
39
+ return this.#insert(identity, time, false);
40
+ });
41
+ }
42
+
43
+ isOwnerSubject(subject) {
44
+ return subject === this.database.ownerId;
45
+ }
46
+
47
+ /** The relay must first verify the exact canonical request on the authorized runtime. */
48
+ adopt(identity, terminal = false) {
49
+ assertOwnershipIdentity(identity, this.database.ownerId);
50
+ if (typeof terminal !== 'boolean') throw relayError('invalid_request_identity');
51
+ const time = this.#time();
52
+ return this.database.transaction(() => {
53
+ const existing = this.get(identity.runtimeId, identity.requestId);
54
+ if (!existing) return this.#insert(identity, time, terminal);
55
+ if (existing.subject !== identity.subject) throw relayError('request_not_owned');
56
+ if (existing.sessionId !== identity.sessionId) throw relayError('request_not_owned');
57
+ return existing;
58
+ });
59
+ }
60
+
61
+ get(runtimeId, requestId) {
62
+ assertRequestIdentity(runtimeId, requestId);
63
+ const record = this.database.prepare(`${SELECT_RECORD} WHERE runtime_id = ? AND request_id = ?`)
64
+ .get(runtimeId, requestId);
65
+ return record ? { ...record } : null;
66
+ }
67
+
68
+ listSession(runtimeId, sessionId) {
69
+ if (!isRelayId(runtimeId) || !isRelayId(sessionId)) throw relayError('invalid_request_identity');
70
+ return this.database.prepare(`${SELECT_RECORD} WHERE runtime_id = ? AND session_id = ?
71
+ ORDER BY created_at, request_id LIMIT ?`)
72
+ .all(runtimeId, sessionId, MAX_OWNERSHIP_RECORDS).map(record => ({ ...record }));
73
+ }
74
+
75
+ markTerminal(runtimeId, requestId) {
76
+ assertRequestIdentity(runtimeId, requestId);
77
+ return this.database.prepare(`UPDATE request_ownership SET status = 'terminal', terminal_at = ?
78
+ WHERE runtime_id = ? AND request_id = ? AND status = 'open'`)
79
+ .run(this.#time(), runtimeId, requestId).changes > 0;
80
+ }
81
+
82
+ /** The caller must have confirmed deletion of the complete canonical session. */
83
+ retireSession(runtimeId, sessionId) {
84
+ if (!isRelayId(runtimeId) || !isRelayId(sessionId)) throw relayError('invalid_request_identity');
85
+ return this.database.prepare('DELETE FROM request_ownership WHERE runtime_id = ? AND session_id = ?')
86
+ .run(runtimeId, sessionId).changes;
87
+ }
88
+
89
+ prune() {
90
+ return this.#deleteExpired(this.#time());
91
+ }
92
+
93
+ #insert(identity, time, terminal) {
94
+ this.#deleteExpired(time);
95
+ const count = this.database.prepare('SELECT count(*) AS count FROM request_ownership').get().count;
96
+ if (count >= MAX_OWNERSHIP_RECORDS) throw relayError('request_capacity');
97
+ this.database.prepare(`INSERT INTO request_ownership
98
+ (runtime_id, request_id, session_id, subject, device_id, status, created_at, terminal_at)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
100
+ .run(identity.runtimeId, identity.requestId, identity.sessionId, identity.subject, identity.deviceId,
101
+ terminal ? 'terminal' : 'open', time, terminal ? time : null);
102
+ return this.get(identity.runtimeId, identity.requestId);
103
+ }
104
+
105
+ #deleteExpired(time) {
106
+ return this.database.prepare("DELETE FROM request_ownership WHERE status = 'terminal' AND terminal_at <= ?")
107
+ .run(time - TERMINAL_RETENTION_MS).changes;
108
+ }
109
+
110
+ #time() {
111
+ const time = this.now();
112
+ if (!Number.isSafeInteger(time) || time < 0) throw new TypeError('Invalid ownership clock');
113
+ return time;
114
+ }
115
+ }