@sema-agent/server 7.50.0 → 7.52.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 (222) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +83 -20
  4. package/dist/adoption/plan.d.ts +4 -4
  5. package/dist/adoption/plan.js +11 -0
  6. package/dist/approval-card.d.ts +26 -2
  7. package/dist/approval-card.js +9 -0
  8. package/dist/approval-hmac.d.ts +1 -1
  9. package/dist/approval-reconciler.d.ts +2 -2
  10. package/dist/approval-reconciler.js +1 -1
  11. package/dist/bench/l8/artifact.d.ts +1 -1
  12. package/dist/bench/s1/arms.d.ts +1 -1
  13. package/dist/bench/s1/live-deps.d.ts +1 -1
  14. package/dist/bench/s1/oracle.d.ts +1 -1
  15. package/dist/bench/s1/row.d.ts +1 -1
  16. package/dist/boot/budget-tracing.d.ts +1 -1
  17. package/dist/boot/budget-tracing.js +2 -5
  18. package/dist/boot/coordinators.js +3 -1
  19. package/dist/boot/deferred-sandbox-path-env.d.ts +1 -1
  20. package/dist/boot/device-lane.d.ts +79 -0
  21. package/dist/boot/device-lane.js +63 -0
  22. package/dist/boot/engine-lease.d.ts +1 -1
  23. package/dist/boot/execution-env.d.ts +48 -2
  24. package/dist/boot/execution-env.js +57 -5
  25. package/dist/boot/governance-seams.d.ts +2 -2
  26. package/dist/boot/leader.d.ts +1 -1
  27. package/dist/boot/memory-boundary.d.ts +2 -2
  28. package/dist/boot/memory-consolidation.d.ts +6 -6
  29. package/dist/boot/org-memory.d.ts +1 -1
  30. package/dist/boot/parked-revive-gate.d.ts +3 -3
  31. package/dist/boot/reapers.d.ts +2 -2
  32. package/dist/boot/reapers.js +17 -4
  33. package/dist/boot/resolve-spec.d.ts +15 -0
  34. package/dist/boot/resolve-spec.js +40 -15
  35. package/dist/boot/retention-lane.d.ts +2 -2
  36. package/dist/boot/runner-deps.d.ts +17 -7
  37. package/dist/boot/runner-deps.js +4 -2
  38. package/dist/boot/runtime-caps.d.ts +23 -0
  39. package/dist/boot/runtime-caps.js +24 -2
  40. package/dist/boot/session-faces.d.ts +6 -1
  41. package/dist/boot/session-faces.js +17 -3
  42. package/dist/boot/shutdown.d.ts +8 -0
  43. package/dist/boot/shutdown.js +3 -1
  44. package/dist/boot/stores.js +40 -1
  45. package/dist/boot/task-list-lane.d.ts +2 -2
  46. package/dist/brain.js +51 -31
  47. package/dist/budget.d.ts +2 -2
  48. package/dist/capabilities/center-plugins.d.ts +1 -1
  49. package/dist/capabilities/collab-workflows.d.ts +1 -1
  50. package/dist/capabilities/hands-lane.d.ts +3 -3
  51. package/dist/capabilities/scenarios.d.ts +3 -3
  52. package/dist/capabilities/skills.d.ts +1 -1
  53. package/dist/config-center/hot-keys-registry.js +5 -0
  54. package/dist/config-center/http-client.d.ts +2 -2
  55. package/dist/config-center/read-face.d.ts +1 -1
  56. package/dist/config-center/restart-signal.d.ts +1 -1
  57. package/dist/config-center/types.d.ts +10 -3
  58. package/dist/config-invariants.d.ts +2 -2
  59. package/dist/config-invariants.js +18 -0
  60. package/dist/config-types.d.ts +90 -18
  61. package/dist/config.js +91 -19
  62. package/dist/deployment-governance.d.ts +1 -1
  63. package/dist/device-enrollment.d.ts +125 -0
  64. package/dist/device-enrollment.js +156 -0
  65. package/dist/device-store.d.ts +385 -0
  66. package/dist/device-store.js +407 -0
  67. package/dist/device-ws-hub.d.ts +182 -0
  68. package/dist/device-ws-hub.js +1012 -0
  69. package/dist/device-ws-protocol.d.ts +429 -0
  70. package/dist/device-ws-protocol.js +464 -0
  71. package/dist/env-facts.d.ts +5 -2
  72. package/dist/env-facts.js +1 -0
  73. package/dist/execution-lane-caps.d.ts +152 -0
  74. package/dist/execution-lane-caps.js +166 -0
  75. package/dist/fleet/fleet-bus.d.ts +1 -1
  76. package/dist/fleet/fleet-reconciler.d.ts +1 -1
  77. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  78. package/dist/http/active-run-conflict.d.ts +2 -2
  79. package/dist/http/principal-gate.d.ts +1 -1
  80. package/dist/http/route-ctx.d.ts +24 -4
  81. package/dist/http/routes/admin-memory-optout.d.ts +48 -0
  82. package/dist/http/routes/admin-memory-optout.js +75 -0
  83. package/dist/http/routes/agents-roster.d.ts +1 -1
  84. package/dist/http/routes/approvals-assistant.js +21 -21
  85. package/dist/http/routes/capabilities.js +27 -11
  86. package/dist/http/routes/diagnostics.d.ts +1 -1
  87. package/dist/http/routes/leader.d.ts +1 -1
  88. package/dist/http/routes/memory-bundle.d.ts +1 -1
  89. package/dist/http/routes/memory-bundle.js +3 -1
  90. package/dist/http/routes/memory-compliance.d.ts +4 -4
  91. package/dist/http/routes/memory-origin.d.ts +4 -4
  92. package/dist/http/routes/memory-origin.js +3 -1
  93. package/dist/http/routes/memory-policy.js +6 -2
  94. package/dist/http/routes/retention-ops.d.ts +1 -1
  95. package/dist/http/routes/rules.d.ts +5 -2
  96. package/dist/http/routes/rules.js +84 -5
  97. package/dist/http/routes/run-memory-capture.d.ts +34 -0
  98. package/dist/http/routes/run-memory-capture.js +97 -0
  99. package/dist/http/routes/runs.d.ts +1 -0
  100. package/dist/http/routes/runs.js +212 -6
  101. package/dist/http/routes/session-sync.d.ts +4 -6
  102. package/dist/http/routes/session-sync.js +60 -47
  103. package/dist/http/routes/sessions.js +61 -188
  104. package/dist/http/routes/shared-memory.d.ts +1 -1
  105. package/dist/http/routes/tasks.js +1 -1
  106. package/dist/http/routes/workflows.js +19 -0
  107. package/dist/http/server.d.ts +49 -13
  108. package/dist/http/server.js +109 -13
  109. package/dist/http/verify-rounds.d.ts +1 -1
  110. package/dist/http/wire-types.d.ts +41 -8
  111. package/dist/leader/endpoint.d.ts +1 -1
  112. package/dist/leader/leader.d.ts +1 -1
  113. package/dist/leader/repair-oracle.d.ts +2 -2
  114. package/dist/leader/repair-oracle.js +1 -1
  115. package/dist/leader/wire.js +4 -2
  116. package/dist/main.js +23 -9
  117. package/dist/memory-bundle-engine.d.ts +1 -1
  118. package/dist/memory-operator-faces.d.ts +5 -5
  119. package/dist/memory-origin-fence.d.ts +50 -0
  120. package/dist/memory-origin-fence.js +15 -0
  121. package/dist/memory-posture.d.ts +13 -1
  122. package/dist/memory-posture.js +1 -0
  123. package/dist/memory-scope.d.ts +6 -1
  124. package/dist/memory-scope.js +3 -2
  125. package/dist/model-select.d.ts +2 -2
  126. package/dist/observability/fail-open.d.ts +17 -5
  127. package/dist/observability/fail-open.js +17 -5
  128. package/dist/orchestration/hardened-vm-runner.d.ts +9 -2
  129. package/dist/orchestration/hardened-vm-runner.js +11 -1
  130. package/dist/orchestration/hardened-vm-worker-runner.js +2 -2
  131. package/dist/orchestration/workflow-completion-inbox.d.ts +2 -2
  132. package/dist/plugins/background-shell-support.d.ts +2 -2
  133. package/dist/plugins/blob-backend.d.ts +7 -2
  134. package/dist/plugins/blob-backend.js +22 -18
  135. package/dist/plugins/caching-session-store.d.ts +7 -3
  136. package/dist/plugins/checkpoint-store-sql.d.ts +2 -2
  137. package/dist/plugins/device-store-sql.d.ts +130 -0
  138. package/dist/plugins/device-store-sql.js +574 -0
  139. package/dist/plugins/file-history-store-sql.d.ts +170 -0
  140. package/dist/plugins/file-history-store-sql.js +827 -0
  141. package/dist/plugins/file-run-store.d.ts +44 -5
  142. package/dist/plugins/file-run-store.js +85 -13
  143. package/dist/plugins/fork-routing-session-store.d.ts +1 -1
  144. package/dist/plugins/fork-routing-session-store.js +2 -2
  145. package/dist/plugins/leader-run-store-sql.d.ts +1 -1
  146. package/dist/plugins/local-session-store.d.ts +9 -1
  147. package/dist/plugins/memory-embedder-fingerprint.d.ts +1 -1
  148. package/dist/plugins/memory-optout-grant-store-sql.d.ts +139 -0
  149. package/dist/plugins/memory-optout-grant-store-sql.js +157 -0
  150. package/dist/plugins/memory-origin-law.d.ts +2 -2
  151. package/dist/plugins/memory-run-store.d.ts +4 -3
  152. package/dist/plugins/memory-run-store.js +2 -1
  153. package/dist/plugins/permission-rule-store-file.d.ts +16 -6
  154. package/dist/plugins/permission-rule-store-file.js +77 -13
  155. package/dist/plugins/permission-rule-store-sql.d.ts +156 -8
  156. package/dist/plugins/permission-rule-store-sql.js +123 -21
  157. package/dist/plugins/pg-pool.js +59 -0
  158. package/dist/plugins/pg-safe-json.d.ts +1 -1
  159. package/dist/plugins/remote-env-device.d.ts +271 -0
  160. package/dist/plugins/remote-env-device.js +727 -0
  161. package/dist/plugins/remote-env-k8s.d.ts +1 -1
  162. package/dist/plugins/remote-scratchpad.js +1 -1
  163. package/dist/plugins/retention-lane-store-sql.d.ts +3 -3
  164. package/dist/plugins/retention-store-sql.d.ts +7 -7
  165. package/dist/plugins/retention-store-sql.js +6 -0
  166. package/dist/plugins/run-store-sql.d.ts +9 -3
  167. package/dist/plugins/run-store-sql.js +2 -2
  168. package/dist/plugins/send-user-file.d.ts +1 -1
  169. package/dist/plugins/session-placement.d.ts +1 -1
  170. package/dist/plugins/sql-errors.d.ts +1 -1
  171. package/dist/plugins/store-backend.d.ts +68 -29
  172. package/dist/plugins/store-backend.js +78 -15
  173. package/dist/plugins/store-contracts.d.ts +5 -3
  174. package/dist/plugins/task-list-store-sql.d.ts +1 -1
  175. package/dist/plugins/tidb-pool.js +59 -0
  176. package/dist/plugins/web-search.d.ts +1 -1
  177. package/dist/prompts-domain-validate.js +1 -1
  178. package/dist/resource-suspend.d.ts +1 -1
  179. package/dist/resource-window.d.ts +3 -3
  180. package/dist/rules-consent.d.ts +231 -9
  181. package/dist/rules-consent.js +411 -13
  182. package/dist/run-local.js +1 -0
  183. package/dist/runs.d.ts +15 -1
  184. package/dist/runs.js +19 -0
  185. package/dist/runtime-caps-resolver.d.ts +36 -1
  186. package/dist/runtime-caps-resolver.js +25 -0
  187. package/dist/runtime-governance.d.ts +1 -1
  188. package/dist/security.d.ts +5 -5
  189. package/dist/security.js +1 -1
  190. package/dist/session-sync.d.ts +52 -37
  191. package/dist/session-sync.js +19 -16
  192. package/dist/shared-memory-scope-authorizer.d.ts +1 -1
  193. package/dist/sighup-idle.d.ts +1 -1
  194. package/dist/spec-fields.d.ts +22 -1
  195. package/dist/spec-fields.js +11 -0
  196. package/dist/store-live-probe.d.ts +2 -2
  197. package/dist/task-a2a.d.ts +1 -1
  198. package/dist/task-cwd.d.ts +25 -0
  199. package/dist/task-cwd.js +3 -0
  200. package/dist/task-mcp.d.ts +3 -3
  201. package/dist/task-settings.d.ts +3 -3
  202. package/dist/task-settings.js +3 -1
  203. package/dist/task-workflow.d.ts +1 -1
  204. package/dist/text-bidi.d.ts +2 -2
  205. package/dist/tool-approval.d.ts +191 -9
  206. package/dist/tool-approval.js +248 -29
  207. package/dist/trace/core-keyset-guard.d.ts +1 -1
  208. package/dist/trace/engine-notice-wire.d.ts +6 -4
  209. package/dist/trace/engine-notice-wire.js +2 -0
  210. package/dist/trace/ledger-events.d.ts +1 -1
  211. package/dist/trace/ledger-sink.d.ts +2 -2
  212. package/dist/trace/project.d.ts +3 -2
  213. package/dist/trace/project.js +1 -0
  214. package/dist/turn-activity.d.ts +1 -1
  215. package/dist/wall-clock-jump-guard.d.ts +3 -3
  216. package/package.json +2 -2
  217. package/dist/http/tar.d.ts +0 -17
  218. package/dist/http/tar.js +0 -53
  219. package/dist/http/workspace-content.d.ts +0 -11
  220. package/dist/http/workspace-content.js +0 -20
  221. package/dist/plugins/file-snapshot-store-sql.d.ts +0 -170
  222. package/dist/plugins/file-snapshot-store-sql.js +0 -213
@@ -0,0 +1,1012 @@
1
+ import http from "node:http";
2
+ import { createHash } from "node:crypto";
3
+ import { WebSocketServer } from "ws";
4
+ import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, assertDeviceIdShape, } from "./device-store.js";
5
+ import { DEVICE_MIN_PROTOCOL_VERSION, DEVICE_WS_LIMITS, DEVICE_WS_PATH, DEVICE_PROTOCOL_VERSION, asInstructionKind, buildHelloSignaturePayload, formatGeneration, mintChallengeNonce, mintInstructionId, parseDeviceFrameText, verifyHelloSignature, } from "./device-ws-protocol.js";
6
+ const OUTCOME_UNKNOWN_SENTENCE = "outcome unknown — the command may have already executed on the device; verify its effect before re-running";
7
+ const SETTLED_MEMO_MAX = 512;
8
+ function rejection(code, message, retryAfterSec) {
9
+ return { code, status: DEVICE_ERROR_STATUS[code], message, ...(retryAfterSec === undefined ? {} : { retryAfterSec }) };
10
+ }
11
+ function capabilityDriftResult(drift) {
12
+ return {
13
+ kind: "never_started",
14
+ outcome: {
15
+ ok: false,
16
+ errorCode: "precondition_failed",
17
+ message: `[device.capability_drift] the connected device declares protective primitives (${drift.join(", ")}) that the caller's environment was minted without — ` +
18
+ "dispatching a non-atomic form here would be exactly the silent protective downgrade this refusal exists to prevent (#157)",
19
+ },
20
+ };
21
+ }
22
+ function outcomeUnknown(why) {
23
+ return { ok: false, errorCode: "outcome_unknown", message: `${why} — ${OUTCOME_UNKNOWN_SENTENCE}` };
24
+ }
25
+ const WRITE_PAYLOAD_KINDS = new Set(["writeFile", "writeFileExclusive", "writeFileGuarded", "appendFile"]);
26
+ const ALLOWED_SUCCESS_OUTCOME_KINDS = {
27
+ exec: ["exec"],
28
+ execStream: ["exec"],
29
+ readTextFile: ["read"],
30
+ readTextLines: ["read"],
31
+ readBinaryFile: ["read"],
32
+ writeFile: ["write"],
33
+ writeFileExclusive: ["write"],
34
+ writeFileGuarded: ["write"],
35
+ appendFile: ["void"],
36
+ fileInfo: ["stat"],
37
+ listDir: ["stat"],
38
+ canonicalPath: ["stat"],
39
+ exists: ["stat"],
40
+ readLink: ["stat"],
41
+ statBatch: ["stat"],
42
+ createDir: ["void"],
43
+ remove: ["void"],
44
+ createTempDir: ["write"],
45
+ createTempFile: ["write"],
46
+ };
47
+ export function createDeviceWsHub(options) {
48
+ const { store, enrollment, replicaId, logger, metrics } = options;
49
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? 15_000;
50
+ const heartbeatMisses = options.heartbeatMisses ?? 3;
51
+ const leaseSeconds = options.leaseSeconds ?? 45;
52
+ const leaseRenewIntervalMs = options.leaseRenewIntervalMs ?? heartbeatIntervalMs;
53
+ const maxInflightPerDevice = options.maxInflightPerDevice ?? 4;
54
+ const dispatchTimeoutMs = options.dispatchTimeoutMs ?? 10_000;
55
+ const execGraceMs = options.execGraceMs ?? 30_000;
56
+ const cancelGraceMs = options.cancelGraceMs ?? 10_000;
57
+ const reconnectGraceMs = options.reconnectGraceMs ?? 120_000;
58
+ const chunkGapTimeoutMs = options.chunkGapTimeoutMs ?? 30_000;
59
+ const supersede = options.supersede ?? "stale_only";
60
+ const chunkBufferMaxEntries = options.chunkBufferMaxEntries ?? 64;
61
+ const chunkBufferMaxBytes = options.chunkBufferMaxBytes ?? 2 * 1024 * 1024;
62
+ const maxPreAuthConnections = options.maxPreAuthConnections ?? 64;
63
+ const maxPreAuthConnectionsPerRemote = options.maxPreAuthConnectionsPerRemote ?? 8;
64
+ const wss = new WebSocketServer({ noServer: true, maxPayload: DEVICE_WS_LIMITS.maxFrameBytes });
65
+ const conns = new Map();
66
+ const allConns = new Set();
67
+ let preAuthCount = 0;
68
+ const preAuthByRemote = new Map();
69
+ const pending = new Map();
70
+ const inflightCount = new Map();
71
+ const slotWaiters = new Map();
72
+ const reconnectTimers = new Map();
73
+ const settledMemo = new Map();
74
+ let draining = false;
75
+ let closed = false;
76
+ function auditFrame(deviceId, owner, reason, detail) {
77
+ metrics?.inc("device_frame_unexpected_total", { reason });
78
+ void store
79
+ .appendAudit({ event: "device_frame_unexpected", deviceId, owner, detail: { reason, ...detail } })
80
+ .catch((err) => logger?.error("device_audit_write_failed", { event: "device_frame_unexpected", reason, err: err instanceof Error ? err.message : String(err) }));
81
+ }
82
+ function auditEvent(event, deviceId, owner, detail) {
83
+ void store
84
+ .appendAudit({ event, deviceId, owner, detail })
85
+ .catch((err) => logger?.error("device_audit_write_failed", { event, err: err instanceof Error ? err.message : String(err) }));
86
+ }
87
+ function sendServerFrame(conn, frame) {
88
+ if (conn.ws.readyState !== conn.ws.OPEN)
89
+ return false;
90
+ try {
91
+ conn.ws.send(JSON.stringify(frame));
92
+ return true;
93
+ }
94
+ catch (err) {
95
+ logger?.warn("device_ws_send_failed", { deviceId: conn.deviceId, t: frame.t, err: err instanceof Error ? err.message : String(err) });
96
+ return false;
97
+ }
98
+ }
99
+ function acquireSlot(deviceId) {
100
+ const cur = inflightCount.get(deviceId) ?? 0;
101
+ if (cur < maxInflightPerDevice) {
102
+ inflightCount.set(deviceId, cur + 1);
103
+ return Promise.resolve(true);
104
+ }
105
+ return new Promise((resolve) => {
106
+ const queue = slotWaiters.get(deviceId) ?? [];
107
+ const waiter = {
108
+ resolve,
109
+ timer: setTimeout(() => {
110
+ const idx = queue.indexOf(waiter);
111
+ if (idx >= 0)
112
+ queue.splice(idx, 1);
113
+ resolve(false);
114
+ }, dispatchTimeoutMs),
115
+ };
116
+ queue.push(waiter);
117
+ slotWaiters.set(deviceId, queue);
118
+ });
119
+ }
120
+ function releaseSlot(deviceId) {
121
+ const queue = slotWaiters.get(deviceId);
122
+ const waiter = queue?.shift();
123
+ if (waiter) {
124
+ clearTimeout(waiter.timer);
125
+ waiter.resolve(true);
126
+ return;
127
+ }
128
+ inflightCount.set(deviceId, Math.max(0, (inflightCount.get(deviceId) ?? 1) - 1));
129
+ }
130
+ function rememberSettled(p) {
131
+ if (settledMemo.size >= SETTLED_MEMO_MAX) {
132
+ const oldest = settledMemo.keys().next();
133
+ if (!oldest.done)
134
+ settledMemo.delete(oldest.value);
135
+ }
136
+ settledMemo.set(p.instructionId, { deviceId: p.deviceId, generation: p.generation });
137
+ }
138
+ function settle(p, outcome) {
139
+ if (p.settled)
140
+ return;
141
+ p.settled = true;
142
+ p.state = "terminal";
143
+ if (p.ceilingTimer)
144
+ clearTimeout(p.ceilingTimer);
145
+ if (p.cancelTimer)
146
+ clearTimeout(p.cancelTimer);
147
+ if (p.chunkGapTimer)
148
+ clearTimeout(p.chunkGapTimer);
149
+ p.ceilingTimer = null;
150
+ p.cancelTimer = null;
151
+ p.chunkGapTimer = null;
152
+ pending.delete(p.instructionId);
153
+ rememberSettled(p);
154
+ releaseSlot(p.deviceId);
155
+ p.resolve(outcome);
156
+ }
157
+ function settleUnknown(p, why) {
158
+ settle(p, outcomeUnknown(why));
159
+ }
160
+ function pendingOfDevice(deviceId) {
161
+ return [...pending.values()].filter((p) => p.deviceId === deviceId);
162
+ }
163
+ function abandonPendingOfDevice(deviceId, why) {
164
+ for (const p of pendingOfDevice(deviceId))
165
+ settleUnknown(p, why);
166
+ }
167
+ function releasePreAuthSlot(conn) {
168
+ if (!conn.holdsPreAuthSlot)
169
+ return;
170
+ conn.holdsPreAuthSlot = false;
171
+ preAuthCount = Math.max(0, preAuthCount - 1);
172
+ const cur = preAuthByRemote.get(conn.remoteKey) ?? 1;
173
+ if (cur <= 1)
174
+ preAuthByRemote.delete(conn.remoteKey);
175
+ else
176
+ preAuthByRemote.set(conn.remoteKey, cur - 1);
177
+ }
178
+ function clearConnTimers(conn) {
179
+ if (conn.handshakeTimer)
180
+ clearTimeout(conn.handshakeTimer);
181
+ if (conn.watchdog)
182
+ clearInterval(conn.watchdog);
183
+ if (conn.leaseTimer)
184
+ clearInterval(conn.leaseTimer);
185
+ if (conn.goodbyeTimer)
186
+ clearTimeout(conn.goodbyeTimer);
187
+ conn.handshakeTimer = null;
188
+ conn.watchdog = null;
189
+ conn.leaseTimer = null;
190
+ conn.goodbyeTimer = null;
191
+ }
192
+ const pendingLeaseReleases = new Map();
193
+ function releaseLeaseTracked(deviceId, generation) {
194
+ const p = store
195
+ .releaseConnection(deviceId, generation)
196
+ .catch((err) => logger?.warn("device_lease_release_failed", { deviceId, err: err instanceof Error ? err.message : String(err) }))
197
+ .then(() => undefined)
198
+ .finally(() => {
199
+ if (pendingLeaseReleases.get(deviceId) === p)
200
+ pendingLeaseReleases.delete(deviceId);
201
+ });
202
+ pendingLeaseReleases.set(deviceId, p);
203
+ return p;
204
+ }
205
+ function closeConn(conn, opts) {
206
+ if (conn.phase === "closing")
207
+ return;
208
+ conn.phase = "closing";
209
+ clearConnTimers(conn);
210
+ releasePreAuthSlot(conn);
211
+ allConns.delete(conn);
212
+ if (conn.deviceId !== "" && conns.get(conn.deviceId) === conn) {
213
+ conns.delete(conn.deviceId);
214
+ if (opts.releaseLease) {
215
+ void releaseLeaseTracked(conn.deviceId, conn.generation);
216
+ }
217
+ if (opts.abandonPending !== undefined)
218
+ abandonPendingOfDevice(conn.deviceId, opts.abandonPending);
219
+ else
220
+ scheduleReconnectGrace(conn.deviceId);
221
+ }
222
+ try {
223
+ conn.ws.close();
224
+ }
225
+ catch (err) {
226
+ logger?.warn("device_ws_close_failed", { deviceId: conn.deviceId, err: err instanceof Error ? err.message : String(err) });
227
+ }
228
+ }
229
+ function scheduleReconnectGrace(deviceId) {
230
+ if (closed)
231
+ return;
232
+ const prior = reconnectTimers.get(deviceId);
233
+ if (prior)
234
+ clearTimeout(prior);
235
+ const timer = setTimeout(() => {
236
+ reconnectTimers.delete(deviceId);
237
+ if (conns.has(deviceId))
238
+ return;
239
+ for (const p of pendingOfDevice(deviceId))
240
+ settleUnknown(p, "the device did not reconnect within the reconnect grace window");
241
+ }, reconnectGraceMs);
242
+ reconnectTimers.set(deviceId, timer);
243
+ }
244
+ function rejectHello(conn, code, minProtocolVersion) {
245
+ if (conn.handshakeTimer)
246
+ clearTimeout(conn.handshakeTimer);
247
+ conn.handshakeTimer = null;
248
+ sendServerFrame(conn, { t: "helloReject", code, ...(minProtocolVersion === undefined ? {} : { minProtocolVersion }) });
249
+ closeConn(conn, { releaseLease: false });
250
+ }
251
+ async function fenceConnection(conn) {
252
+ if (conn.phase === "closing" || conn.deviceId === "")
253
+ return false;
254
+ let held = false;
255
+ try {
256
+ held = await store.heartbeatConnection({ deviceId: conn.deviceId, generation: conn.generation, leaseSeconds });
257
+ }
258
+ catch (err) {
259
+ logger?.warn("device_lease_renew_failed", { deviceId: conn.deviceId, err: err instanceof Error ? err.message : String(err) });
260
+ return "unavailable";
261
+ }
262
+ if (held) {
263
+ conn.lastLeaseRenewAt = Date.now();
264
+ return true;
265
+ }
266
+ let revoked = false;
267
+ try {
268
+ const row = await store.getDeviceUnscoped(conn.deviceId);
269
+ revoked = row?.status === "revoked";
270
+ }
271
+ catch (err) {
272
+ logger?.warn("device_row_read_failed", { deviceId: conn.deviceId, err: err instanceof Error ? err.message : String(err) });
273
+ }
274
+ metrics?.inc("device_fence_lost_total", { reason: revoked ? "revoked" : "superseded" });
275
+ sendServerFrame(conn, { t: "bye", gen: conn.genStr, reason: revoked ? "revoked" : "superseded" });
276
+ closeConn(conn, { releaseLease: false, abandonPending: revoked ? "the device was revoked while this instruction was in flight" : "the device connection lease was taken over elsewhere while this instruction was in flight" });
277
+ return false;
278
+ }
279
+ async function renewLeaseThrottled(conn) {
280
+ if (conn.phase !== "active")
281
+ return;
282
+ const floor = Math.max(50, Math.floor(leaseRenewIntervalMs / 3));
283
+ if (Date.now() - conn.lastLeaseRenewAt < floor)
284
+ return;
285
+ conn.lastLeaseRenewAt = Date.now();
286
+ await fenceConnection(conn);
287
+ }
288
+ function startConnTimers(conn) {
289
+ conn.watchdog = setInterval(() => {
290
+ if (Date.now() - conn.lastFrameAt > heartbeatIntervalMs * heartbeatMisses) {
291
+ logger?.warn("device_heartbeat_lost", { deviceId: conn.deviceId, generation: conn.generation });
292
+ metrics?.inc("device_heartbeat_lost_total");
293
+ closeConn(conn, { releaseLease: true });
294
+ }
295
+ }, Math.max(50, Math.floor(heartbeatIntervalMs / 2)));
296
+ conn.leaseTimer = setInterval(() => {
297
+ if (Date.now() - conn.lastFrameAt > heartbeatIntervalMs * heartbeatMisses)
298
+ return;
299
+ conn.lastLeaseRenewAt = Date.now();
300
+ void fenceConnection(conn);
301
+ }, leaseRenewIntervalMs);
302
+ }
303
+ function handshakeStillLive(conn) {
304
+ return conn.phase === "authenticating" && conn.ws.readyState === conn.ws.OPEN;
305
+ }
306
+ async function handleHello(conn, frame) {
307
+ if (draining || closed)
308
+ return rejectHello(conn, "draining");
309
+ if (frame.protocolVersion < DEVICE_MIN_PROTOCOL_VERSION || frame.protocolVersion > DEVICE_PROTOCOL_VERSION) {
310
+ return rejectHello(conn, "protocol_version_unsupported", DEVICE_MIN_PROTOCOL_VERSION);
311
+ }
312
+ let idShapeOk = true;
313
+ try {
314
+ assertDeviceIdShape(frame.deviceId);
315
+ }
316
+ catch {
317
+ idShapeOk = false;
318
+ }
319
+ let row = null;
320
+ if (idShapeOk) {
321
+ try {
322
+ row = await store.getDeviceUnscoped(frame.deviceId);
323
+ }
324
+ catch (err) {
325
+ logger?.error("device_hello_store_read_failed", { err: err instanceof Error ? err.message : String(err) });
326
+ return rejectHello(conn, "auth_failed");
327
+ }
328
+ }
329
+ if (!handshakeStillLive(conn))
330
+ return closeConn(conn, { releaseLease: false });
331
+ let payload = null;
332
+ if (row) {
333
+ try {
334
+ payload = buildHelloSignaturePayload({ nonce: conn.nonce, deviceId: frame.deviceId, epoch: frame.epoch, protocolVersion: frame.protocolVersion });
335
+ }
336
+ catch {
337
+ payload = null;
338
+ }
339
+ }
340
+ if (row === null || payload === null)
341
+ return rejectHello(conn, "auth_failed");
342
+ const verdict = verifyHelloSignature({ pubkeyB64: row.pubkey, signatureB64: frame.signatureB64, payload });
343
+ if (!verdict.ok) {
344
+ logger?.warn("device_hello_signature_rejected", { deviceId: frame.deviceId, reason: verdict.reason });
345
+ metrics?.inc("device_hello_rejected_total", { code: "auth_failed" });
346
+ return rejectHello(conn, "auth_failed");
347
+ }
348
+ if (row.status === "revoked")
349
+ return rejectHello(conn, "revoked");
350
+ const existing = conns.get(row.deviceId);
351
+ if (existing && existing !== conn) {
352
+ const fresh = Date.now() - existing.lastFrameAt < heartbeatIntervalMs * heartbeatMisses;
353
+ if (supersede === "deny" || fresh) {
354
+ auditEvent("device_attached_elsewhere", row.deviceId, row.owner, { at: "hello", reason: supersede === "deny" ? "supersede knob is deny" : "the existing connection is still fresh", replicaId });
355
+ return rejectHello(conn, "already_connected");
356
+ }
357
+ auditEvent("device_superseded", row.deviceId, row.owner, { at: "hello", supersededGeneration: existing.generation, replicaId });
358
+ sendServerFrame(existing, { t: "bye", gen: existing.genStr, reason: "superseded" });
359
+ closeConn(existing, { releaseLease: true });
360
+ }
361
+ const inflightRelease = pendingLeaseReleases.get(row.deviceId);
362
+ if (inflightRelease)
363
+ await inflightRelease;
364
+ if (!handshakeStillLive(conn))
365
+ return closeConn(conn, { releaseLease: false });
366
+ let acquired;
367
+ try {
368
+ acquired = await store.acquireConnection({ deviceId: row.deviceId, owner: row.owner, connEpoch: frame.epoch, replicaId, leaseSeconds });
369
+ }
370
+ catch (err) {
371
+ logger?.error("device_acquire_failed", { deviceId: row.deviceId, err: err instanceof Error ? err.message : String(err) });
372
+ return rejectHello(conn, "auth_failed");
373
+ }
374
+ switch (acquired.outcome) {
375
+ case "attached_elsewhere":
376
+ auditEvent("device_attached_elsewhere", row.deviceId, row.owner, { at: "acquire", heldGeneration: acquired.generation, replicaId });
377
+ return rejectHello(conn, "already_connected");
378
+ case "device_not_found":
379
+ return rejectHello(conn, "auth_failed");
380
+ case "device_revoked":
381
+ return rejectHello(conn, "revoked");
382
+ case "acquired":
383
+ break;
384
+ }
385
+ if (!handshakeStillLive(conn)) {
386
+ void releaseLeaseTracked(row.deviceId, acquired.generation);
387
+ return closeConn(conn, { releaseLease: false });
388
+ }
389
+ const priorTimer = reconnectTimers.get(row.deviceId);
390
+ if (priorTimer) {
391
+ clearTimeout(priorTimer);
392
+ reconnectTimers.delete(row.deviceId);
393
+ }
394
+ const pendingIds = [];
395
+ const chunkWatermarks = {};
396
+ for (const p of pendingOfDevice(row.deviceId)) {
397
+ if (p.epoch !== frame.epoch) {
398
+ settleUnknown(p, "the executor reconnected with a new process epoch, so its in-flight instructions were abandoned");
399
+ continue;
400
+ }
401
+ p.generation = acquired.generation;
402
+ pendingIds.push(p.instructionId);
403
+ chunkWatermarks[p.instructionId] = p.nextStreamSeq - 1;
404
+ }
405
+ conn.deviceId = row.deviceId;
406
+ conn.owner = row.owner;
407
+ conn.epoch = frame.epoch;
408
+ conn.generation = acquired.generation;
409
+ conn.genStr = formatGeneration(acquired.generation);
410
+ conn.workspaceRoot = frame.workspaceRoot;
411
+ const supported = new Set();
412
+ for (const s of frame.supports) {
413
+ const k = asInstructionKind(s);
414
+ if (k !== null)
415
+ supported.add(k);
416
+ }
417
+ conn.supports = supported;
418
+ conn.phase = "acquiring";
419
+ conn.lastLeaseRenewAt = Date.now();
420
+ conns.set(row.deviceId, conn);
421
+ if (conn.handshakeTimer)
422
+ clearTimeout(conn.handshakeTimer);
423
+ conn.handshakeTimer = null;
424
+ releasePreAuthSlot(conn);
425
+ sendServerFrame(conn, { t: "helloAck", ok: true, heartbeatIntervalMs, maxInflight: maxInflightPerDevice, gen: conn.genStr, pendingInstructions: pendingIds, chunkWatermarks });
426
+ logger?.info?.("device_connected", { deviceId: row.deviceId, generation: acquired.generation, epoch: frame.epoch, replicaId });
427
+ metrics?.inc("device_connected_total");
428
+ }
429
+ function deliverChunks(conn, p) {
430
+ let delivered = false;
431
+ for (;;) {
432
+ const next = p.chunkBuffer.get(p.nextStreamSeq);
433
+ if (!next)
434
+ break;
435
+ p.chunkBuffer.delete(p.nextStreamSeq);
436
+ p.chunkBufferBytes -= chunkPayloadBytes(next);
437
+ p.nextStreamSeq += 1;
438
+ p.lastDeliveredSeq = next.streamSeq;
439
+ if (next.kind === "exit") {
440
+ p.exitChunkCount += 1;
441
+ p.exitChunk = { streamSeq: next.streamSeq, exitCode: next.exitCode ?? 0 };
442
+ }
443
+ delivered = true;
444
+ if (p.onChunk) {
445
+ try {
446
+ p.onChunk({
447
+ streamSeq: next.streamSeq,
448
+ kind: next.kind,
449
+ ...(next.dataB64 === undefined ? {} : { data: Buffer.from(next.dataB64, "base64") }),
450
+ ...(next.exitCode === undefined ? {} : { exitCode: next.exitCode }),
451
+ });
452
+ }
453
+ catch (err) {
454
+ logger?.error("device_chunk_consumer_threw", { instructionId: p.instructionId, err: err instanceof Error ? err.message : String(err) });
455
+ }
456
+ }
457
+ }
458
+ if (delivered)
459
+ sendServerFrame(conn, { t: "chunkAck", gen: conn.genStr, instructionId: p.instructionId, upToStreamSeq: p.nextStreamSeq - 1 });
460
+ if (p.chunkBuffer.size > 0 && p.chunkGapTimer === null) {
461
+ p.chunkGapTimer = setTimeout(() => failOnChunkGap(p), chunkGapTimeoutMs);
462
+ }
463
+ else if (p.chunkBuffer.size === 0 && p.chunkGapTimer !== null) {
464
+ clearTimeout(p.chunkGapTimer);
465
+ p.chunkGapTimer = null;
466
+ }
467
+ }
468
+ function failOnChunkGap(p) {
469
+ const missing = p.nextStreamSeq;
470
+ settle(p, {
471
+ ok: false,
472
+ errorCode: "outcome_unknown",
473
+ message: `the output stream had a gap (missing chunk streamSeq ${missing}) so the result cannot be reported faithfully — ${OUTCOME_UNKNOWN_SENTENCE}`,
474
+ });
475
+ }
476
+ function chunkPayloadBytes(frame) {
477
+ return frame.dataB64 === undefined ? 0 : Math.floor((frame.dataB64.length * 3) / 4);
478
+ }
479
+ function streamTerminatorComplaint(p, outcome) {
480
+ if (outcome.ok === true && outcome.kind !== "cancelled" && !ALLOWED_SUCCESS_OUTCOME_KINDS[p.kind].includes(outcome.kind)) {
481
+ return `the device answered a ${p.kind} instruction with a ${outcome.kind} outcome (allowed: ${ALLOWED_SUCCESS_OUTCOME_KINDS[p.kind].join("|")}|cancelled)`;
482
+ }
483
+ if (!p.expectsStreamTerminator)
484
+ return null;
485
+ if (!(outcome.ok === true && outcome.kind === "exec"))
486
+ return null;
487
+ if (p.exitChunkCount === 0)
488
+ return "the stream never delivered its single exit chunk, so its tail may have been truncated";
489
+ if (p.exitChunkCount > 1)
490
+ return `the stream delivered ${p.exitChunkCount} exit chunks (the contract allows exactly one)`;
491
+ if (p.exitChunk !== null && p.exitChunk.streamSeq !== p.lastDeliveredSeq)
492
+ return "the exit chunk was not the last chunk of the stream";
493
+ if (p.exitChunk !== null && p.exitChunk.exitCode !== outcome.exitCode)
494
+ return `the exit chunk reported exitCode ${p.exitChunk.exitCode} but the result reported ${outcome.exitCode}`;
495
+ return null;
496
+ }
497
+ function matchPending(conn, instructionId, t) {
498
+ const p = pending.get(instructionId);
499
+ if (!p) {
500
+ const memo = settledMemo.get(instructionId);
501
+ if (!memo) {
502
+ auditFrame(conn.deviceId, conn.owner, "unknown_instruction", { instructionId, t });
503
+ return { rejected: true };
504
+ }
505
+ if (memo.deviceId !== conn.deviceId) {
506
+ auditFrame(conn.deviceId, conn.owner, "device_mismatch", { instructionId, boundDeviceId: memo.deviceId, settled: true, t });
507
+ return { rejected: true };
508
+ }
509
+ return { memo: true };
510
+ }
511
+ if (p.deviceId !== conn.deviceId) {
512
+ auditFrame(conn.deviceId, conn.owner, "device_mismatch", { instructionId, boundDeviceId: p.deviceId, t });
513
+ return { rejected: true };
514
+ }
515
+ if (p.state !== "dispatch_committed") {
516
+ auditFrame(conn.deviceId, conn.owner, "instruction_not_committed", { instructionId, state: p.state, t });
517
+ return { rejected: true };
518
+ }
519
+ return { hit: p };
520
+ }
521
+ async function acceptTerminal(conn, p, outcome) {
522
+ p.terminalFenceInFlight = true;
523
+ let fenced;
524
+ try {
525
+ fenced = await fenceConnection(conn);
526
+ }
527
+ finally {
528
+ p.terminalFenceInFlight = false;
529
+ }
530
+ if (p.settled)
531
+ return;
532
+ if (p.chunkBuffer.size > 0) {
533
+ failOnChunkGap(p);
534
+ return;
535
+ }
536
+ if (fenced !== true) {
537
+ auditFrame(conn.deviceId, conn.owner, "generation_mismatch", {
538
+ instructionId: p.instructionId,
539
+ t: "result",
540
+ detail: fenced === "unavailable" ? "the store was unavailable so the terminal could not be fenced" : "the connection no longer holds the device lease",
541
+ });
542
+ settleUnknown(p, fenced === "unavailable" ? "the device lease could not be re-asserted when its result arrived" : "the device lost its lease before its result could be accepted");
543
+ return;
544
+ }
545
+ const complaint = streamTerminatorComplaint(p, outcome);
546
+ if (complaint !== null) {
547
+ metrics?.inc("device_stream_terminator_missing_total");
548
+ sendServerFrame(conn, { t: "resultAck", gen: conn.genStr, instructionId: p.instructionId });
549
+ settle(p, { ok: false, errorCode: "outcome_unknown", message: `${complaint} — ${OUTCOME_UNKNOWN_SENTENCE}` });
550
+ return;
551
+ }
552
+ sendServerFrame(conn, { t: "resultAck", gen: conn.genStr, instructionId: p.instructionId });
553
+ settle(p, outcome);
554
+ }
555
+ function onDeviceText(conn, text) {
556
+ conn.lastFrameAt = Date.now();
557
+ const preAuth = conn.phase === "await_hello" || conn.phase === "authenticating";
558
+ const cap = preAuth ? DEVICE_WS_LIMITS.maxPreAuthFrameBytes : DEVICE_WS_LIMITS.maxFrameBytes;
559
+ const parsed = parseDeviceFrameText(text, { maxBytes: cap });
560
+ if (!parsed.ok) {
561
+ auditFrame(conn.deviceId === "" ? null : conn.deviceId, conn.owner, parsed.reason, { detail: parsed.detail, phase: conn.phase });
562
+ if (preAuth)
563
+ closeConn(conn, { releaseLease: false });
564
+ return;
565
+ }
566
+ const frame = parsed.frame;
567
+ if (preAuth) {
568
+ if (conn.phase === "authenticating" || conn.nonceConsumed) {
569
+ auditFrame(conn.deviceId === "" ? null : conn.deviceId, conn.owner, "pre_hello", { t: frame.t, phase: conn.phase, reason: "a handshake is already in flight on this socket" });
570
+ return;
571
+ }
572
+ if (frame.t !== "hello") {
573
+ auditFrame(null, null, "pre_hello", { t: frame.t, phase: conn.phase });
574
+ closeConn(conn, { releaseLease: false });
575
+ return;
576
+ }
577
+ conn.nonceConsumed = true;
578
+ conn.phase = "authenticating";
579
+ void handleHello(conn, frame).catch((err) => {
580
+ logger?.error("device_hello_failed", { err: err instanceof Error ? err.message : String(err) });
581
+ closeConn(conn, { releaseLease: false });
582
+ });
583
+ return;
584
+ }
585
+ if (frame.t === "hello") {
586
+ auditFrame(conn.deviceId, conn.owner, "pre_hello", { t: "hello", phase: conn.phase });
587
+ return;
588
+ }
589
+ if (frame.gen !== conn.genStr || conns.get(conn.deviceId) !== conn) {
590
+ auditFrame(conn.deviceId, conn.owner, "generation_mismatch", { t: frame.t, frameGen: frame.gen, socketGen: conn.genStr });
591
+ return;
592
+ }
593
+ switch (frame.t) {
594
+ case "generationAck": {
595
+ if (conn.phase === "acquiring") {
596
+ conn.phase = "active";
597
+ startConnTimers(conn);
598
+ for (const p of pendingOfDevice(conn.deviceId))
599
+ if (p.cancelRequested)
600
+ deliverCancel(p);
601
+ }
602
+ return;
603
+ }
604
+ case "heartbeat": {
605
+ void renewLeaseThrottled(conn);
606
+ return;
607
+ }
608
+ case "result": {
609
+ const m = matchPending(conn, frame.instructionId, "result");
610
+ if ("rejected" in m)
611
+ return;
612
+ if ("memo" in m) {
613
+ sendServerFrame(conn, { t: "resultAck", gen: conn.genStr, instructionId: frame.instructionId });
614
+ return;
615
+ }
616
+ const p = m.hit;
617
+ if (p.terminalFenceInFlight)
618
+ return;
619
+ if (frame.deviceSeq <= p.lastDeviceSeq) {
620
+ sendServerFrame(conn, { t: "resultAck", gen: conn.genStr, instructionId: p.instructionId });
621
+ return;
622
+ }
623
+ p.lastDeviceSeq = frame.deviceSeq;
624
+ if (p.chunkBuffer.size > 0) {
625
+ failOnChunkGap(p);
626
+ return;
627
+ }
628
+ void acceptTerminal(conn, p, frame.outcome);
629
+ return;
630
+ }
631
+ case "chunk": {
632
+ const m = matchPending(conn, frame.instructionId, "chunk");
633
+ if ("rejected" in m)
634
+ return;
635
+ if ("memo" in m)
636
+ return;
637
+ const p = m.hit;
638
+ if (frame.streamSeq < p.nextStreamSeq) {
639
+ sendServerFrame(conn, { t: "chunkAck", gen: conn.genStr, instructionId: p.instructionId, upToStreamSeq: p.nextStreamSeq - 1 });
640
+ return;
641
+ }
642
+ if (p.terminalFenceInFlight) {
643
+ auditFrame(conn.deviceId, conn.owner, "instruction_not_committed", { instructionId: p.instructionId, t: "chunk", detail: "a new stream chunk arrived after this instruction's terminal result" });
644
+ settleUnknown(p, `the device sent stream chunk ${frame.streamSeq} after it had already reported this instruction's terminal result, so the stream is not self-consistent`);
645
+ return;
646
+ }
647
+ if (p.chunkBuffer.has(frame.streamSeq)) {
648
+ sendServerFrame(conn, { t: "chunkAck", gen: conn.genStr, instructionId: p.instructionId, upToStreamSeq: p.nextStreamSeq - 1 });
649
+ return;
650
+ }
651
+ const wouldBeEntries = p.chunkBuffer.size + 1;
652
+ const wouldBeBytes = p.chunkBufferBytes + chunkPayloadBytes(frame);
653
+ if (wouldBeEntries > chunkBufferMaxEntries || wouldBeBytes > chunkBufferMaxBytes) {
654
+ auditFrame(conn.deviceId, conn.owner, "bad_shape", {
655
+ instructionId: p.instructionId,
656
+ detail: `reorder buffer cap exceeded (entries ${wouldBeEntries}/${chunkBufferMaxEntries}, bytes ${wouldBeBytes}/${chunkBufferMaxBytes})`,
657
+ });
658
+ metrics?.inc("device_chunk_buffer_overflow_total");
659
+ settleUnknown(p, `the out-of-order chunk buffer exceeded its cap while waiting for streamSeq ${p.nextStreamSeq}, so the output stream cannot be reconstructed`);
660
+ return;
661
+ }
662
+ p.chunkBufferBytes = wouldBeBytes;
663
+ p.chunkBuffer.set(frame.streamSeq, frame);
664
+ deliverChunks(conn, p);
665
+ return;
666
+ }
667
+ case "goodbye": {
668
+ conn.goodbye = true;
669
+ logger?.info?.("device_goodbye", { deviceId: conn.deviceId, reason: frame.reason });
670
+ const grace = frame.graceMs ?? reconnectGraceMs;
671
+ if (conn.goodbyeTimer)
672
+ clearTimeout(conn.goodbyeTimer);
673
+ conn.goodbyeTimer = setTimeout(() => closeConn(conn, { releaseLease: true }), grace);
674
+ return;
675
+ }
676
+ }
677
+ }
678
+ function refuseUpgrade(socket, status) {
679
+ const reason = http.STATUS_CODES[status] ?? "Error";
680
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
681
+ socket.destroy();
682
+ }
683
+ function onSocket(ws, remoteKey) {
684
+ const conn = {
685
+ ws,
686
+ nonce: mintChallengeNonce(),
687
+ nonceConsumed: false,
688
+ remoteKey,
689
+ holdsPreAuthSlot: true,
690
+ deviceId: "",
691
+ owner: null,
692
+ epoch: "",
693
+ generation: 0,
694
+ genStr: "",
695
+ phase: "await_hello",
696
+ supports: new Set(),
697
+ workspaceRoot: "",
698
+ lastFrameAt: Date.now(),
699
+ lastLeaseRenewAt: 0,
700
+ issuedSeq: 0,
701
+ goodbye: false,
702
+ handshakeTimer: null,
703
+ watchdog: null,
704
+ leaseTimer: null,
705
+ goodbyeTimer: null,
706
+ };
707
+ allConns.add(conn);
708
+ ws.on("error", (err) => {
709
+ logger?.warn("device_ws_socket_error", { deviceId: conn.deviceId, err: err.message });
710
+ closeConn(conn, { releaseLease: conn.phase === "active" || conn.phase === "acquiring" });
711
+ });
712
+ ws.on("close", () => {
713
+ closeConn(conn, { releaseLease: true });
714
+ });
715
+ ws.on("message", (data, isBinary) => {
716
+ if (isBinary) {
717
+ auditFrame(conn.deviceId === "" ? null : conn.deviceId, conn.owner, "not_json", { detail: "binary frame on a text-only protocol" });
718
+ if (conn.phase === "await_hello")
719
+ closeConn(conn, { releaseLease: false });
720
+ return;
721
+ }
722
+ onDeviceText(conn, data.toString("utf8"));
723
+ });
724
+ conn.handshakeTimer = setTimeout(() => {
725
+ logger?.warn("device_handshake_timeout", {});
726
+ closeConn(conn, { releaseLease: false });
727
+ }, DEVICE_WS_LIMITS.handshakeDeadlineMs);
728
+ sendServerFrame(conn, { t: "challenge", nonce: conn.nonce, protocolVersion: DEVICE_PROTOCOL_VERSION });
729
+ }
730
+ function attach(server) {
731
+ server.on("upgrade", (req, socket, head) => {
732
+ const path = (req.url ?? "/").split("?")[0];
733
+ if (path !== DEVICE_WS_PATH)
734
+ return refuseUpgrade(socket, 404);
735
+ if (draining || closed)
736
+ return refuseUpgrade(socket, 503);
737
+ const remoteKey = req.socket.remoteAddress ?? "unknown";
738
+ if (preAuthCount >= maxPreAuthConnections || (preAuthByRemote.get(remoteKey) ?? 0) >= maxPreAuthConnectionsPerRemote) {
739
+ metrics?.inc("device_preauth_refused_total");
740
+ logger?.warn("device_preauth_cap_hit", { remoteKey, preAuthCount, cap: maxPreAuthConnections });
741
+ return refuseUpgrade(socket, 503);
742
+ }
743
+ preAuthCount += 1;
744
+ preAuthByRemote.set(remoteKey, (preAuthByRemote.get(remoteKey) ?? 0) + 1);
745
+ let handed = false;
746
+ const refundIfUnhanded = () => {
747
+ if (handed)
748
+ return;
749
+ handed = true;
750
+ preAuthCount = Math.max(0, preAuthCount - 1);
751
+ const cur = preAuthByRemote.get(remoteKey) ?? 1;
752
+ if (cur <= 1)
753
+ preAuthByRemote.delete(remoteKey);
754
+ else
755
+ preAuthByRemote.set(remoteKey, cur - 1);
756
+ };
757
+ socket.once("close", refundIfUnhanded);
758
+ socket.once("error", refundIfUnhanded);
759
+ wss.handleUpgrade(req, socket, head, (ws) => {
760
+ handed = true;
761
+ onSocket(ws, remoteKey);
762
+ });
763
+ });
764
+ }
765
+ function sendPayloadSequence(conn, instructionId, body, partBytes, sha256) {
766
+ sendServerFrame(conn, { t: "payload", gen: conn.genStr, instructionId, part: "begin", totalLen: body.length, sha256 });
767
+ for (let offset = 0; offset < body.length; offset += partBytes) {
768
+ sendServerFrame(conn, { t: "payload", gen: conn.genStr, instructionId, part: "chunk", offset, dataB64: body.subarray(offset, offset + partBytes).toString("base64") });
769
+ }
770
+ sendServerFrame(conn, { t: "payload", gen: conn.genStr, instructionId, part: "commit", totalLen: body.length, sha256 });
771
+ }
772
+ async function dispatch(input) {
773
+ if (input.payloadPartBytes !== undefined) {
774
+ const n = input.payloadPartBytes;
775
+ if (!Number.isSafeInteger(n) || n <= 0 || n > DEVICE_WS_LIMITS.maxFrameBytes) {
776
+ throw new Error(`device-ws: payloadPartBytes must be a positive safe integer ≤ ${DEVICE_WS_LIMITS.maxFrameBytes} (got ${String(n)})`);
777
+ }
778
+ }
779
+ if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0) {
780
+ throw new Error(`device-ws: timeoutMs must be a positive safe integer (got ${String(input.timeoutMs)})`);
781
+ }
782
+ const abortedBeforeCommit = () => ({
783
+ kind: "never_started",
784
+ outcome: { ok: false, errorCode: "aborted", message: "the caller aborted before this instruction crossed the dispatch commit" },
785
+ });
786
+ const abortedNow = () => input.signal?.aborted === true;
787
+ if (abortedNow())
788
+ return abortedBeforeCommit();
789
+ if (closed)
790
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, "this device hub is shutting down", 1) };
791
+ if (draining)
792
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, "this replica is draining for shutdown/upgrade and no longer issues new device instructions", 1) };
793
+ const admitted = await enrollment.admitSession({ rootSessionId: input.rootSessionId, requester: input.requester });
794
+ if (!admitted.ok)
795
+ return { kind: "rejected", reject: admitted.reject };
796
+ if (abortedNow())
797
+ return abortedBeforeCommit();
798
+ const { deviceId, connGeneration } = admitted.value;
799
+ const conn = conns.get(deviceId);
800
+ if (!conn || conn.phase !== "active" || conn.goodbye) {
801
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.OFFLINE, "the bound device has no dispatchable connection on this replica") };
802
+ }
803
+ if (!conn.supports.has(input.kind)) {
804
+ return { kind: "never_started", outcome: { ok: false, errorCode: "not_supported", message: `the connected device does not declare support for the ${input.kind} instruction` } };
805
+ }
806
+ const driftNow = () => {
807
+ const cur = conns.get(deviceId);
808
+ return cur === undefined ? [] : (input.refuseIfDeclared ?? []).filter((k) => cur.supports.has(k));
809
+ };
810
+ {
811
+ const drift = driftNow();
812
+ if (drift.length > 0)
813
+ return capabilityDriftResult(drift);
814
+ }
815
+ const granted = await acquireSlot(deviceId);
816
+ if (!granted) {
817
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, `the device already has ${maxInflightPerDevice} instructions in flight`, 1) };
818
+ }
819
+ if (abortedNow()) {
820
+ releaseSlot(deviceId);
821
+ return abortedBeforeCommit();
822
+ }
823
+ {
824
+ const drift = driftNow();
825
+ if (drift.length > 0) {
826
+ releaseSlot(deviceId);
827
+ return capabilityDriftResult(drift);
828
+ }
829
+ }
830
+ if (conn.generation !== connGeneration || conns.get(deviceId) !== conn || conn.phase !== "active") {
831
+ releaseSlot(deviceId);
832
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.ATTACHED_ELSEWHERE, "the device connection generation moved between admission and dispatch") };
833
+ }
834
+ let fenced = false;
835
+ try {
836
+ fenced = await store.heartbeatConnection({ deviceId, generation: conn.generation, leaseSeconds });
837
+ }
838
+ catch (err) {
839
+ releaseSlot(deviceId);
840
+ logger?.error("device_dispatch_fence_failed", { deviceId, err: err instanceof Error ? err.message : String(err) });
841
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.OFFLINE, "the device lease could not be re-asserted before dispatch") };
842
+ }
843
+ if (!fenced) {
844
+ releaseSlot(deviceId);
845
+ let revoked = false;
846
+ try {
847
+ const row = await store.getDeviceUnscoped(deviceId);
848
+ revoked = row?.status === "revoked";
849
+ }
850
+ catch (err) {
851
+ logger?.warn("device_row_read_failed", { deviceId, err: err instanceof Error ? err.message : String(err) });
852
+ }
853
+ sendServerFrame(conn, { t: "bye", gen: conn.genStr, reason: revoked ? "revoked" : "superseded" });
854
+ closeConn(conn, { releaseLease: false });
855
+ return { kind: "rejected", reject: rejection(revoked ? DEVICE_ERROR_CODES.REVOKED : DEVICE_ERROR_CODES.ATTACHED_ELSEWHERE, "the device connection lost its lease before this instruction was dispatched") };
856
+ }
857
+ if (conn.ws.readyState !== conn.ws.OPEN) {
858
+ releaseSlot(deviceId);
859
+ return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.OFFLINE, "the device socket closed before dispatch") };
860
+ }
861
+ if (abortedNow()) {
862
+ releaseSlot(deviceId);
863
+ return abortedBeforeCommit();
864
+ }
865
+ const instructionId = mintInstructionId();
866
+ conn.issuedSeq += 1;
867
+ let resolve;
868
+ const settled = new Promise((r) => {
869
+ resolve = r;
870
+ });
871
+ const p = {
872
+ instructionId,
873
+ deviceId,
874
+ rootSessionId: input.rootSessionId,
875
+ kind: input.kind,
876
+ epoch: conn.epoch,
877
+ generation: conn.generation,
878
+ state: "dispatch_committed",
879
+ lastDeviceSeq: -1,
880
+ nextStreamSeq: 0,
881
+ chunkBuffer: new Map(),
882
+ chunkBufferBytes: 0,
883
+ chunkGapTimer: null,
884
+ ceilingTimer: null,
885
+ cancelTimer: null,
886
+ cancelRequested: false,
887
+ cancelDeliveredOnGeneration: null,
888
+ settled: false,
889
+ terminalFenceInFlight: false,
890
+ expectsStreamTerminator: input.onChunk !== undefined || input.kind === "execStream",
891
+ exitChunk: null,
892
+ exitChunkCount: 0,
893
+ lastDeliveredSeq: -1,
894
+ ...(input.onChunk === undefined ? {} : { onChunk: input.onChunk }),
895
+ resolve,
896
+ };
897
+ pending.set(instructionId, p);
898
+ let args = input.args;
899
+ let body = null;
900
+ let sha = "";
901
+ if (input.payload !== undefined && WRITE_PAYLOAD_KINDS.has(input.kind)) {
902
+ body = Buffer.from(input.payload);
903
+ sha = createHash("sha256").update(body).digest("hex");
904
+ args = { ...input.args, contentLen: body.length, sha256: sha };
905
+ }
906
+ const wrote = sendServerFrame(conn, {
907
+ t: "instruction",
908
+ gen: conn.genStr,
909
+ instructionId,
910
+ rootSessionId: input.rootSessionId,
911
+ issuedSeq: conn.issuedSeq,
912
+ kind: input.kind,
913
+ args,
914
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
915
+ ...(input.shellEnv === undefined ? {} : { shellEnv: input.shellEnv }),
916
+ timeoutMs: input.timeoutMs,
917
+ });
918
+ if (body !== null)
919
+ sendPayloadSequence(conn, instructionId, body, input.payloadPartBytes ?? DEVICE_WS_LIMITS.defaultPayloadPartBytes, sha);
920
+ metrics?.inc("device_instruction_dispatched_total", { kind: input.kind });
921
+ if (!wrote) {
922
+ settleUnknown(p, "the instruction could not be written to the device socket after the dispatch commit");
923
+ return { kind: "settled", instructionId, outcome: await settled };
924
+ }
925
+ p.ceilingTimer = setTimeout(() => {
926
+ metrics?.inc("device_outcome_unknown_total", { cause: "exec_ceiling" });
927
+ settleUnknown(p, `the device did not report a result within timeoutMs+grace (${input.timeoutMs}+${execGraceMs}ms)`);
928
+ }, input.timeoutMs + execGraceMs);
929
+ let onAbort;
930
+ if (input.signal) {
931
+ if (input.signal.aborted)
932
+ void cancel(instructionId);
933
+ else {
934
+ onAbort = () => void cancel(instructionId);
935
+ input.signal.addEventListener("abort", onAbort, { once: true });
936
+ }
937
+ }
938
+ try {
939
+ return { kind: "settled", instructionId, outcome: await settled };
940
+ }
941
+ finally {
942
+ if (onAbort)
943
+ input.signal?.removeEventListener("abort", onAbort);
944
+ }
945
+ }
946
+ function deliverCancel(p) {
947
+ if (p.settled || p.state !== "dispatch_committed")
948
+ return;
949
+ const conn = conns.get(p.deviceId);
950
+ if (!conn || conn.phase !== "active")
951
+ return;
952
+ if (p.cancelDeliveredOnGeneration === conn.generation)
953
+ return;
954
+ if (!sendServerFrame(conn, { t: "cancel", gen: conn.genStr, instructionId: p.instructionId }))
955
+ return;
956
+ p.cancelDeliveredOnGeneration = conn.generation;
957
+ if (p.cancelTimer)
958
+ clearTimeout(p.cancelTimer);
959
+ p.cancelTimer = setTimeout(() => {
960
+ metrics?.inc("device_outcome_unknown_total", { cause: "cancel_grace" });
961
+ settleUnknown(p, `the device did not return a terminal receipt within ${cancelGraceMs}ms of the cancel`);
962
+ }, cancelGraceMs);
963
+ }
964
+ async function cancel(instructionId) {
965
+ const p = pending.get(instructionId);
966
+ if (!p || p.state !== "dispatch_committed")
967
+ return;
968
+ p.cancelRequested = true;
969
+ deliverCancel(p);
970
+ return Promise.resolve();
971
+ }
972
+ function beginDrain() {
973
+ draining = true;
974
+ logger?.info?.("device_hub_draining", { connections: conns.size, pending: pending.size });
975
+ }
976
+ async function close() {
977
+ closed = true;
978
+ draining = true;
979
+ for (const timer of reconnectTimers.values())
980
+ clearTimeout(timer);
981
+ reconnectTimers.clear();
982
+ for (const conn of [...allConns]) {
983
+ if (conn.phase === "active" || conn.phase === "acquiring")
984
+ sendServerFrame(conn, { t: "bye", gen: conn.genStr, reason: "draining" });
985
+ closeConn(conn, { releaseLease: true });
986
+ }
987
+ for (const p of [...pending.values()])
988
+ settleUnknown(p, "the device hub shut down while this instruction was in flight");
989
+ for (const queue of slotWaiters.values()) {
990
+ for (const w of queue) {
991
+ clearTimeout(w.timer);
992
+ w.resolve(false);
993
+ }
994
+ }
995
+ slotWaiters.clear();
996
+ await new Promise((resolve) => wss.close(() => resolve()));
997
+ }
998
+ return {
999
+ attach,
1000
+ dispatch,
1001
+ cancel,
1002
+ beginDrain,
1003
+ isDraining: () => draining,
1004
+ connectedDeviceIds: () => [...conns.keys()],
1005
+ declaredSupports: (deviceId) => {
1006
+ const conn = conns.get(deviceId);
1007
+ return conn && (conn.phase === "active" || conn.phase === "acquiring") ? conn.supports : undefined;
1008
+ },
1009
+ close,
1010
+ };
1011
+ }
1012
+ //# sourceMappingURL=device-ws-hub.js.map