@superblocksteam/gateway 2.0.0-SNAPSHOT.2161f9a02a4eb5d8

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 (239) hide show
  1. package/LICENSE.txt +87 -0
  2. package/README.md +284 -0
  3. package/dist/agents/resolve-orchestrator-url.d.ts +22 -0
  4. package/dist/agents/resolve-orchestrator-url.js +38 -0
  5. package/dist/agents/resolve-orchestrator-url.js.map +1 -0
  6. package/dist/capabilities/integration-metadata.d.ts +28 -0
  7. package/dist/capabilities/integration-metadata.js +174 -0
  8. package/dist/capabilities/integration-metadata.js.map +1 -0
  9. package/dist/capabilities/lifecycle.d.ts +93 -0
  10. package/dist/capabilities/lifecycle.js +3417 -0
  11. package/dist/capabilities/lifecycle.js.map +1 -0
  12. package/dist/capabilities/query-integration.d.ts +14 -0
  13. package/dist/capabilities/query-integration.js +172 -0
  14. package/dist/capabilities/query-integration.js.map +1 -0
  15. package/dist/capabilities/source-files-archive.d.ts +11 -0
  16. package/dist/capabilities/source-files-archive.js +54 -0
  17. package/dist/capabilities/source-files-archive.js.map +1 -0
  18. package/dist/capabilities/types.d.ts +542 -0
  19. package/dist/capabilities/types.js +86 -0
  20. package/dist/capabilities/types.js.map +1 -0
  21. package/dist/capture/browser-contract.d.ts +46 -0
  22. package/dist/capture/browser-contract.js +34 -0
  23. package/dist/capture/browser-contract.js.map +1 -0
  24. package/dist/capture/browser-instructions.d.ts +10 -0
  25. package/dist/capture/browser-instructions.js +18 -0
  26. package/dist/capture/browser-instructions.js.map +1 -0
  27. package/dist/capture/capture-library-probe.d.ts +55 -0
  28. package/dist/capture/capture-library-probe.js +177 -0
  29. package/dist/capture/capture-library-probe.js.map +1 -0
  30. package/dist/capture/capture-library.d.ts +52 -0
  31. package/dist/capture/capture-library.js +249 -0
  32. package/dist/capture/capture-library.js.map +1 -0
  33. package/dist/capture/classify-library-paint.d.ts +21 -0
  34. package/dist/capture/classify-library-paint.js +13 -0
  35. package/dist/capture/classify-library-paint.js.map +1 -0
  36. package/dist/capture/host-mcp-scan.d.ts +16 -0
  37. package/dist/capture/host-mcp-scan.js +64 -0
  38. package/dist/capture/host-mcp-scan.js.map +1 -0
  39. package/dist/capture/library-parent-script.d.ts +35 -0
  40. package/dist/capture/library-parent-script.js +82 -0
  41. package/dist/capture/library-parent-script.js.map +1 -0
  42. package/dist/capture/mode.d.ts +29 -0
  43. package/dist/capture/mode.js +65 -0
  44. package/dist/capture/mode.js.map +1 -0
  45. package/dist/config.d.ts +148 -0
  46. package/dist/config.js +112 -0
  47. package/dist/config.js.map +1 -0
  48. package/dist/debug.d.ts +2 -0
  49. package/dist/debug.js +9 -0
  50. package/dist/debug.js.map +1 -0
  51. package/dist/deps.d.ts +38 -0
  52. package/dist/deps.js +2 -0
  53. package/dist/deps.js.map +1 -0
  54. package/dist/health/readiness.d.ts +99 -0
  55. package/dist/health/readiness.js +202 -0
  56. package/dist/health/readiness.js.map +1 -0
  57. package/dist/index.d.ts +8 -0
  58. package/dist/index.js +8 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/integrations/filters.d.ts +8 -0
  61. package/dist/integrations/filters.js +40 -0
  62. package/dist/integrations/filters.js.map +1 -0
  63. package/dist/integrations/map.d.ts +27 -0
  64. package/dist/integrations/map.js +43 -0
  65. package/dist/integrations/map.js.map +1 -0
  66. package/dist/integrations/profile-key.d.ts +19 -0
  67. package/dist/integrations/profile-key.js +35 -0
  68. package/dist/integrations/profile-key.js.map +1 -0
  69. package/dist/integrations/read-only-postgres-query.d.ts +2 -0
  70. package/dist/integrations/read-only-postgres-query.js +164 -0
  71. package/dist/integrations/read-only-postgres-query.js.map +1 -0
  72. package/dist/main.d.ts +2 -0
  73. package/dist/main.js +5 -0
  74. package/dist/main.js.map +1 -0
  75. package/dist/orchestrator/client.d.ts +62 -0
  76. package/dist/orchestrator/client.js +160 -0
  77. package/dist/orchestrator/client.js.map +1 -0
  78. package/dist/playwright/ensure-chromium.d.ts +23 -0
  79. package/dist/playwright/ensure-chromium.js +59 -0
  80. package/dist/playwright/ensure-chromium.js.map +1 -0
  81. package/dist/preview/capture-screenshot.d.ts +67 -0
  82. package/dist/preview/capture-screenshot.js +179 -0
  83. package/dist/preview/capture-screenshot.js.map +1 -0
  84. package/dist/preview/viewer-url.d.ts +82 -0
  85. package/dist/preview/viewer-url.js +130 -0
  86. package/dist/preview/viewer-url.js.map +1 -0
  87. package/dist/process/fault-barrier.d.ts +55 -0
  88. package/dist/process/fault-barrier.js +113 -0
  89. package/dist/process/fault-barrier.js.map +1 -0
  90. package/dist/sabs/agent-facing-text.d.ts +35 -0
  91. package/dist/sabs/agent-facing-text.js +165 -0
  92. package/dist/sabs/agent-facing-text.js.map +1 -0
  93. package/dist/sabs/app-state.d.ts +133 -0
  94. package/dist/sabs/app-state.js +347 -0
  95. package/dist/sabs/app-state.js.map +1 -0
  96. package/dist/sabs/awaited-decision.d.ts +47 -0
  97. package/dist/sabs/awaited-decision.js +124 -0
  98. package/dist/sabs/awaited-decision.js.map +1 -0
  99. package/dist/sabs/browser-context.d.ts +53 -0
  100. package/dist/sabs/browser-context.js +92 -0
  101. package/dist/sabs/browser-context.js.map +1 -0
  102. package/dist/sabs/editor-client-methods.d.ts +164 -0
  103. package/dist/sabs/editor-client-methods.js +749 -0
  104. package/dist/sabs/editor-client-methods.js.map +1 -0
  105. package/dist/sabs/editor-socket.d.ts +84 -0
  106. package/dist/sabs/editor-socket.js +64 -0
  107. package/dist/sabs/editor-socket.js.map +1 -0
  108. package/dist/sabs/recent-tool-runs.d.ts +14 -0
  109. package/dist/sabs/recent-tool-runs.js +61 -0
  110. package/dist/sabs/recent-tool-runs.js.map +1 -0
  111. package/dist/sabs/session-peer.d.ts +386 -0
  112. package/dist/sabs/session-peer.js +11 -0
  113. package/dist/sabs/session-peer.js.map +1 -0
  114. package/dist/sabs/streamed-reply.d.ts +42 -0
  115. package/dist/sabs/streamed-reply.js +110 -0
  116. package/dist/sabs/streamed-reply.js.map +1 -0
  117. package/dist/sabs/turn-collector.d.ts +169 -0
  118. package/dist/sabs/turn-collector.js +512 -0
  119. package/dist/sabs/turn-collector.js.map +1 -0
  120. package/dist/sabs/websocket-session-peer.d.ts +533 -0
  121. package/dist/sabs/websocket-session-peer.js +3241 -0
  122. package/dist/sabs/websocket-session-peer.js.map +1 -0
  123. package/dist/security/constant-time.d.ts +9 -0
  124. package/dist/security/constant-time.js +13 -0
  125. package/dist/security/constant-time.js.map +1 -0
  126. package/dist/server/client.d.ts +400 -0
  127. package/dist/server/client.js +649 -0
  128. package/dist/server/client.js.map +1 -0
  129. package/dist/server/identity-reads.d.ts +76 -0
  130. package/dist/server/identity-reads.js +209 -0
  131. package/dist/server/identity-reads.js.map +1 -0
  132. package/dist/start.d.ts +24 -0
  133. package/dist/start.js +81 -0
  134. package/dist/start.js.map +1 -0
  135. package/dist/stores/memory.d.ts +42 -0
  136. package/dist/stores/memory.js +121 -0
  137. package/dist/stores/memory.js.map +1 -0
  138. package/dist/stores/types.d.ts +74 -0
  139. package/dist/stores/types.js +11 -0
  140. package/dist/stores/types.js.map +1 -0
  141. package/dist/telemetry/mcp-client.d.ts +22 -0
  142. package/dist/telemetry/mcp-client.js +140 -0
  143. package/dist/telemetry/mcp-client.js.map +1 -0
  144. package/dist/telemetry/metrics.d.ts +41 -0
  145. package/dist/telemetry/metrics.js +103 -0
  146. package/dist/telemetry/metrics.js.map +1 -0
  147. package/dist/telemetry/runtime.d.ts +9 -0
  148. package/dist/telemetry/runtime.js +118 -0
  149. package/dist/telemetry/runtime.js.map +1 -0
  150. package/dist/transports/mcp/admin-tools.d.ts +51 -0
  151. package/dist/transports/mcp/admin-tools.js +211 -0
  152. package/dist/transports/mcp/admin-tools.js.map +1 -0
  153. package/dist/transports/mcp/app-status-html.d.ts +11 -0
  154. package/dist/transports/mcp/app-status-html.js +899 -0
  155. package/dist/transports/mcp/app-status-html.js.map +1 -0
  156. package/dist/transports/mcp/client-presentation.d.ts +16 -0
  157. package/dist/transports/mcp/client-presentation.js +13 -0
  158. package/dist/transports/mcp/client-presentation.js.map +1 -0
  159. package/dist/transports/mcp/decision-card-html.d.ts +26 -0
  160. package/dist/transports/mcp/decision-card-html.js +876 -0
  161. package/dist/transports/mcp/decision-card-html.js.map +1 -0
  162. package/dist/transports/mcp/decision-elicitation.d.ts +116 -0
  163. package/dist/transports/mcp/decision-elicitation.js +292 -0
  164. package/dist/transports/mcp/decision-elicitation.js.map +1 -0
  165. package/dist/transports/mcp/dispatch-probe.d.ts +24 -0
  166. package/dist/transports/mcp/dispatch-probe.js +48 -0
  167. package/dist/transports/mcp/dispatch-probe.js.map +1 -0
  168. package/dist/transports/mcp/format-tool-content.d.ts +20 -0
  169. package/dist/transports/mcp/format-tool-content.js +89 -0
  170. package/dist/transports/mcp/format-tool-content.js.map +1 -0
  171. package/dist/transports/mcp/instructions/index.d.ts +23 -0
  172. package/dist/transports/mcp/instructions/index.js +81 -0
  173. package/dist/transports/mcp/instructions/index.js.map +1 -0
  174. package/dist/transports/mcp/instructions/result.d.ts +18 -0
  175. package/dist/transports/mcp/instructions/result.js +58 -0
  176. package/dist/transports/mcp/instructions/result.js.map +1 -0
  177. package/dist/transports/mcp/instructions/tools/ask-user.d.ts +2 -0
  178. package/dist/transports/mcp/instructions/tools/ask-user.js +17 -0
  179. package/dist/transports/mcp/instructions/tools/ask-user.js.map +1 -0
  180. package/dist/transports/mcp/instructions/tools/check-app-progress.d.ts +3 -0
  181. package/dist/transports/mcp/instructions/tools/check-app-progress.js +72 -0
  182. package/dist/transports/mcp/instructions/tools/check-app-progress.js.map +1 -0
  183. package/dist/transports/mcp/instructions/tools/check-publish-progress.d.ts +2 -0
  184. package/dist/transports/mcp/instructions/tools/check-publish-progress.js +14 -0
  185. package/dist/transports/mcp/instructions/tools/check-publish-progress.js.map +1 -0
  186. package/dist/transports/mcp/instructions/tools/copy.d.ts +8 -0
  187. package/dist/transports/mcp/instructions/tools/copy.js +19 -0
  188. package/dist/transports/mcp/instructions/tools/copy.js.map +1 -0
  189. package/dist/transports/mcp/instructions/tools/create-integration.d.ts +3 -0
  190. package/dist/transports/mcp/instructions/tools/create-integration.js +32 -0
  191. package/dist/transports/mcp/instructions/tools/create-integration.js.map +1 -0
  192. package/dist/transports/mcp/instructions/tools/edit-app.d.ts +2 -0
  193. package/dist/transports/mcp/instructions/tools/edit-app.js +14 -0
  194. package/dist/transports/mcp/instructions/tools/edit-app.js.map +1 -0
  195. package/dist/transports/mcp/instructions/tools/get-app.d.ts +3 -0
  196. package/dist/transports/mcp/instructions/tools/get-app.js +47 -0
  197. package/dist/transports/mcp/instructions/tools/get-app.js.map +1 -0
  198. package/dist/transports/mcp/instructions/tools/get-integration-metadata.d.ts +2 -0
  199. package/dist/transports/mcp/instructions/tools/get-integration-metadata.js +13 -0
  200. package/dist/transports/mcp/instructions/tools/get-integration-metadata.js.map +1 -0
  201. package/dist/transports/mcp/instructions/tools/index.d.ts +7 -0
  202. package/dist/transports/mcp/instructions/tools/index.js +26 -0
  203. package/dist/transports/mcp/instructions/tools/index.js.map +1 -0
  204. package/dist/transports/mcp/instructions/tools/preview-app.d.ts +3 -0
  205. package/dist/transports/mcp/instructions/tools/preview-app.js +14 -0
  206. package/dist/transports/mcp/instructions/tools/preview-app.js.map +1 -0
  207. package/dist/transports/mcp/instructions/tools/publish-app.d.ts +2 -0
  208. package/dist/transports/mcp/instructions/tools/publish-app.js +11 -0
  209. package/dist/transports/mcp/instructions/tools/publish-app.js.map +1 -0
  210. package/dist/transports/mcp/instructions/tools/start-app.d.ts +2 -0
  211. package/dist/transports/mcp/instructions/tools/start-app.js +18 -0
  212. package/dist/transports/mcp/instructions/tools/start-app.js.map +1 -0
  213. package/dist/transports/mcp/instructions/tools/upload-artifact.d.ts +2 -0
  214. package/dist/transports/mcp/instructions/tools/upload-artifact.js +13 -0
  215. package/dist/transports/mcp/instructions/tools/upload-artifact.js.map +1 -0
  216. package/dist/transports/mcp/mcp-app-brand-css.d.ts +1 -0
  217. package/dist/transports/mcp/mcp-app-brand-css.js +180 -0
  218. package/dist/transports/mcp/mcp-app-brand-css.js.map +1 -0
  219. package/dist/transports/mcp/mount.d.ts +21 -0
  220. package/dist/transports/mcp/mount.js +968 -0
  221. package/dist/transports/mcp/mount.js.map +1 -0
  222. package/dist/transports/mcp/native-browser-presence.d.ts +45 -0
  223. package/dist/transports/mcp/native-browser-presence.js +158 -0
  224. package/dist/transports/mcp/native-browser-presence.js.map +1 -0
  225. package/dist/transports/mcp/plan-approval.d.ts +38 -0
  226. package/dist/transports/mcp/plan-approval.js +175 -0
  227. package/dist/transports/mcp/plan-approval.js.map +1 -0
  228. package/dist/transports/mcp/progress-notifier.d.ts +39 -0
  229. package/dist/transports/mcp/progress-notifier.js +52 -0
  230. package/dist/transports/mcp/progress-notifier.js.map +1 -0
  231. package/dist/transports/mcp/session-directory.d.ts +7 -0
  232. package/dist/transports/mcp/session-directory.js +18 -0
  233. package/dist/transports/mcp/session-directory.js.map +1 -0
  234. package/dist/transports/mcp/tool-names.d.ts +2 -0
  235. package/dist/transports/mcp/tool-names.js +2 -0
  236. package/dist/transports/mcp/tool-names.js.map +1 -0
  237. package/package.json +78 -0
  238. package/skills/superblocks-build/SKILL.md +59 -0
  239. package/skills/superblocks-import/SKILL.md +87 -0
@@ -0,0 +1,3241 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WebSocket } from "ws";
3
+ import { createISocketClient, ISocket, SocketHeartbeat, } from "@superblocksteam/shared";
4
+ import { pickOrchestratorBaseUrl } from "../agents/resolve-orchestrator-url.js";
5
+ import { pickEditorProfileKey } from "../integrations/profile-key.js";
6
+ import { HttpOrchestratorClient, } from "../orchestrator/client.js";
7
+ import { recordCheckpointOutcome, recordGatewayFault, } from "../telemetry/metrics.js";
8
+ import { sanitizeAgentFacingText } from "./agent-facing-text.js";
9
+ import { AppState } from "./app-state.js";
10
+ import { buildBrowserContext } from "./browser-context.js";
11
+ import { createEditorClientMethods, loadIntegrationsForPrompt, } from "./editor-client-methods.js";
12
+ import { EditorSocket } from "./editor-socket.js";
13
+ import { appendRecentToolRun, buildRecentToolRun } from "./recent-tool-runs.js";
14
+ import { PromptNotSentError, } from "./session-peer.js";
15
+ import { DEFAULT_SETTLE_MS, TurnCollector } from "./turn-collector.js";
16
+ const RELEASE_TURN_LOCK_METHOD = "editor.releaseTurnLock";
17
+ function editorStructureScopeKey(applicationId, branch) {
18
+ return `${applicationId}\u0000${branch?.trim() ?? ""}`;
19
+ }
20
+ /**
21
+ * Whole budget for connecting and getting the prompt acknowledged, so a
22
+ * session that accepts the socket but never answers cannot strand the MCP call
23
+ * waiting on it.
24
+ *
25
+ * A sandbox that has gone idle since the app was created needs most of a
26
+ * minute before it acknowledges anything, and the first edit after that pause
27
+ * was failing on a tighter budget only to succeed on the retry.
28
+ */
29
+ const DEFAULT_TIMEOUT_MS = 120_000;
30
+ /**
31
+ * The sandbox binds its HTTP port before it attaches the socket handler, and
32
+ * an upgrade arriving in that window gets no response at all rather than a
33
+ * refusal. One long attempt would spend the whole budget waiting on a session
34
+ * that was seconds from ready, so each attempt is short and retried.
35
+ */
36
+ const DEFAULT_CONNECT_ATTEMPT_TIMEOUT_MS = 3_000;
37
+ const CONNECT_RETRY_DELAY_MS = 500;
38
+ /**
39
+ * How long a lost liveness beat has to stay lost before the turn running on
40
+ * that socket is given up on.
41
+ *
42
+ * The beat is declared lost for *reporting* after a couple of genuine misses,
43
+ * which is the right threshold for a diagnostic and the wrong one for ending a
44
+ * turn: a sandbox that misses two beats and then answers is recovering, not
45
+ * gone. This is the second gate — the silence has to outlast it, and any beat
46
+ * that succeeds clears it. Two missed beats on the default interval plus this
47
+ * is a couple of minutes, against a ten-minute turn budget.
48
+ */
49
+ const DEFAULT_BEAT_LOST_GRACE_MS = 60_000;
50
+ const DEFAULT_BEAT_HEALTH_TIMEOUT_MS = 3_000;
51
+ async function liveEditIsHealthy(input) {
52
+ if (!input.sessionToken) {
53
+ return false;
54
+ }
55
+ try {
56
+ const response = await fetch(new URL("/_sb_health", input.liveEditUrl).toString(), {
57
+ headers: {
58
+ Authorization: `Bearer ${input.sessionToken}`,
59
+ },
60
+ signal: AbortSignal.timeout(DEFAULT_BEAT_HEALTH_TIMEOUT_MS),
61
+ });
62
+ if (!response.ok) {
63
+ return false;
64
+ }
65
+ const body = await response.json();
66
+ return (typeof body === "object" &&
67
+ body !== null &&
68
+ "applicationId" in body &&
69
+ body.applicationId === input.applicationId &&
70
+ "status" in body &&
71
+ body.status === "healthy");
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
78
+ * Consecutive upgrade rejections from one host before asking where the live
79
+ * edit went. More than one, so an ingress route that briefly answers for a pod
80
+ * that is not up yet is waited out rather than read as a move.
81
+ *
82
+ * Exported so a test can say "kept trying past the point where it asked"
83
+ * without restating the number.
84
+ */
85
+ export const HOST_REJECTIONS_BEFORE_RERESOLVE = 2;
86
+ /**
87
+ * How many times one connect may ask where the session went.
88
+ *
89
+ * Bounded because each ask is a Superblocks API round trip, and spaced by a
90
+ * fresh rejection streak so the asks land across the connect window rather
91
+ * than all at the start of it - SABS needs time to notice the pod is gone
92
+ * before it can answer with anywhere different.
93
+ */
94
+ export const MAX_RERESOLVES_PER_OPEN = 3;
95
+ /** Match AppManager's idle window so a quiet Claude turn does not outlive it. */
96
+ const DEFAULT_IDLE_MS = 30 * 60 * 1000;
97
+ /**
98
+ * Grace period between a turn settling and the gateway socket being dropped.
99
+ *
100
+ * Long enough for the checkpoint and any trailing pushes to land, short enough
101
+ * that the sandbox is not held open by a peer with nothing left to do.
102
+ *
103
+ * Must outlast {@link DEFAULT_SETTLE_MS}. The teardown this release leads to is
104
+ * read as a dead live edit by any turn whose `completed` had not settled yet,
105
+ * so releasing inside the settle window would report healthy builds as
106
+ * failures. `releases the socket only after a completed has had its settle
107
+ * window` holds this.
108
+ */
109
+ export const DEFAULT_RELEASE_AFTER_TURN_MS = 30_000;
110
+ /**
111
+ * Refresh connectLiveEdit while held so local AppManager lastAccessed does not
112
+ * age out under a long Clark build with no MCP traffic.
113
+ */
114
+ const DEFAULT_KEEPALIVE_MS = 5 * 60 * 1000;
115
+ /**
116
+ * How often the held socket proves the gateway is still alive.
117
+ *
118
+ * The sandbox expires a gateway peer's liveness signal if nothing refreshes it,
119
+ * so this has to be comfortably inside that window — a build that runs for
120
+ * twenty quiet minutes must not look abandoned — while still letting a peer that
121
+ * really died stop protecting the sandbox promptly.
122
+ */
123
+ const DEFAULT_HEARTBEAT_MS = 30 * 1000;
124
+ /**
125
+ * How long one beat waits for its answer before it counts as missed.
126
+ *
127
+ * Deliberately far below the prompt budget. `requestWithTracing` takes no
128
+ * per-request deadline, so a beat issued on the held client inherits the
129
+ * socket's own — the whole prompt timeout — and a beat that waits that long
130
+ * cannot report a miss inside any window a caller waits in, nor before three
131
+ * more beats have been sent after it.
132
+ */
133
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 15 * 1000;
134
+ /**
135
+ * Shortest wait for an unanswered beat worth putting in a death report, since
136
+ * the report states it in whole seconds and anything under one is what a
137
+ * healthy round trip looks like at the instant a socket closes.
138
+ */
139
+ const MIN_REPORTED_BEAT_WAIT_MS = 1_000;
140
+ /**
141
+ * How long to wait after Clark acks the prompt for the generation turn to
142
+ * reach a settled terminal state (completed after quiet settle, cancelled,
143
+ * or needs_decision).
144
+ */
145
+ const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
146
+ /**
147
+ * What a caller is told when the live edit backing its turn is gone. The cause
148
+ * is appended by whichever path noticed: a close code and heartbeat account
149
+ * when the socket dropped, nothing when SABS said so outright.
150
+ */
151
+ const LIVE_EDIT_TERMINATED_REPLY = "The Superblocks live-edit session terminated before the turn finished, so nothing was built. Send the prompt again to start it over.";
152
+ /**
153
+ * How long a turn waits for Clark's first push before suspecting the sandbox is
154
+ * not reporting into this socket.
155
+ *
156
+ * The dev server routes a turn's pushes to the peer that asked for it, so the
157
+ * usual cause of silence here is this connection itself — dropped, or opened
158
+ * against a session that had already lost the turn. A healthy turn reports
159
+ * `generating` within a second of the acknowledgment, so this window is
160
+ * silence, not slowness.
161
+ */
162
+ const DEFAULT_PUSH_WAIT_MS = 10_000;
163
+ /** How long to wait for the flushed pushes after reattaching. */
164
+ const DEFAULT_REATTACH_WAIT_MS = 20_000;
165
+ /**
166
+ * How long a turn keeps trying to get back onto its live edit after a close it
167
+ * did not ask for.
168
+ *
169
+ * A close is not an answer about the sandbox: the same 1006 covers a proxy
170
+ * idle-reap, a load-balancer hiccup and a dev server that died. Reaching the
171
+ * live edit again settles it, and failing to reach it for this long is the
172
+ * evidence the close itself never was.
173
+ */
174
+ const DEFAULT_RECONNECT_GRACE_MS = 20_000;
175
+ /**
176
+ * How many times one turn goes back for a live edit that keeps dropping it.
177
+ *
178
+ * A sandbox that closes every socket it accepts is not one a turn can finish
179
+ * on, and each attempt is cheap enough that an uncapped retry would chase it
180
+ * for the whole turn budget.
181
+ */
182
+ const DEFAULT_RECONNECT_ATTEMPTS = 3;
183
+ /**
184
+ * After the job settles, how long to wait for the dev server to finish flushing
185
+ * Clark's files before giving up on checkpointing them.
186
+ */
187
+ const DEFAULT_READY_WAIT_MS = 30_000;
188
+ const PENDING_START_POLL_INTERVAL_MS = 250;
189
+ /**
190
+ * The role the dev server classifies this connection as. A gateway peer gets
191
+ * Clark's pushes for the turns it starts and its own sandbox-liveness signal,
192
+ * without being counted as a browser editor: it never takes the editor
193
+ * binding, and a user's own tab keeps its presence and its lock.
194
+ *
195
+ * Offering the role is not the same as being given it. A `ws` server echoes the
196
+ * first subprotocol it was offered whether or not it knows the name, so a dev
197
+ * server that predates the role accepts this socket, answers `gateway`, and
198
+ * still classifies it as a library socket that never receives Clark's pushes.
199
+ * The handshake cannot tell the two apart; the acknowledgment can, because only
200
+ * a server that routed the turn to this peer can name the turn back. See
201
+ * {@link WebSocketSessionPeer.headlessTurnHandle}.
202
+ */
203
+ const GATEWAY_PROTOCOL = "gateway";
204
+ const DEFAULT_LLM = {
205
+ coreModel: "claude-sonnet-4-5",
206
+ provider: "bedrock",
207
+ };
208
+ /**
209
+ * Reads a {@link LiveEdit} off the caller-facing pair, which names the same two
210
+ * things with the prompt's field names.
211
+ */
212
+ function liveEditOf(source) {
213
+ return {
214
+ ...(source.liveEditId ? { id: source.liveEditId } : {}),
215
+ url: source.liveEditUrl,
216
+ };
217
+ }
218
+ function isBrowserObserverEvent(event) {
219
+ return ((event.kind === "message" &&
220
+ event.message.gatewayObserverSource === "browser") ||
221
+ (event.kind === "generation_state" &&
222
+ event.gatewayObserverSource === "browser"));
223
+ }
224
+ export function buildSessionUrl(input) {
225
+ // liveEditUrl is server-sourced (connectLiveEdit), not caller-supplied, so a
226
+ // host allowlist would mostly duplicate the Superblocks API's own trust
227
+ // boundary. The scheme check still matters: anything other than http(s)
228
+ // would let a compromised response smuggle the session token off-protocol.
229
+ const url = new URL("/sb-updates", input.liveEditUrl);
230
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
231
+ throw new Error(`Prompt forward failed: live-edit URL must be http(s), got ${url.protocol}`);
232
+ }
233
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
234
+ url.searchParams.set("peerId", input.peerId);
235
+ if (input.userId) {
236
+ url.searchParams.set("userId", input.userId);
237
+ }
238
+ url.searchParams.set("applicationId", input.applicationId);
239
+ return url;
240
+ }
241
+ function isAuthRejection(error) {
242
+ return error.code === 401 || error.code === 403;
243
+ }
244
+ /**
245
+ * Whether a host answered the upgrade by denying it serves this live edit, as
246
+ * opposed to not answering it.
247
+ *
248
+ * The difference is the whole reason the connect loop retries. A booting
249
+ * sandbox binds its port before attaching the socket handler and drops the
250
+ * upgrade in silence, so a cold start looks like an attempt timeout and is
251
+ * worth waiting out on the same host. A status in the 404 family is something
252
+ * answering on that host and saying the session is not there - which is what a
253
+ * stale session -> host binding looks like from the outside, and no amount of
254
+ * retrying the same host will fix it.
255
+ *
256
+ * 5xx is deliberately not included: that is the session's own host reporting it
257
+ * is not ready yet, which is the retry case, not a move.
258
+ */
259
+ function isHostRejection(error) {
260
+ const message = error instanceof Error ? error.message : String(error);
261
+ // The wording is ws's own for a non-101 response to the handshake.
262
+ const status = /Unexpected server response: (\d{3})/.exec(message)?.[1];
263
+ return status === "404" || status === "410";
264
+ }
265
+ /**
266
+ * ISocket gives up on an unanswered request in two different wordings —
267
+ * "Request timed out after N seconds" when its own no-response timer fires,
268
+ * and "Abort timeout" when the underlying signal aborts first. Both mean the
269
+ * same thing to a caller, and neither says what to do about it.
270
+ */
271
+ function isTimeout(error) {
272
+ return /timed out|timeout/i.test(error.message ?? "");
273
+ }
274
+ function isRemoteRejection(error) {
275
+ // ISocket reserves 7-10 for timeout, connection close, abort, and a request
276
+ // made after close. Every other numbered code arrived in a response.
277
+ return (error.code !== undefined &&
278
+ error.code !== 7 &&
279
+ error.code !== 8 &&
280
+ error.code !== 9 &&
281
+ error.code !== 10);
282
+ }
283
+ function isSocketOpen(ws) {
284
+ return ws.readyState === WebSocket.OPEN;
285
+ }
286
+ /**
287
+ * Says what a rejected socket request actually failed on.
288
+ *
289
+ * Every rejection this client produces is a plain `{ code, message }` object
290
+ * and never an `Error` — a refusal from the wire, a no-response timeout, and a
291
+ * closed connection all take that shape. `String()` on one of those yields
292
+ * "[object Object]" and throws away the only account of the cause there is.
293
+ */
294
+ function describeSocketError(error) {
295
+ if (error instanceof Error) {
296
+ return error.message;
297
+ }
298
+ if (typeof error !== "object" || error === null) {
299
+ return String(error);
300
+ }
301
+ const { code, message } = error;
302
+ if (typeof message === "string" && message.length > 0) {
303
+ return code === undefined ? message : `${message} (code ${code})`;
304
+ }
305
+ try {
306
+ return JSON.stringify(error);
307
+ }
308
+ catch {
309
+ return String(error);
310
+ }
311
+ }
312
+ function checkpointFailure(error) {
313
+ const detail = typeof error === "object" && error !== null
314
+ ? error
315
+ : undefined;
316
+ const rawMessage = error instanceof Error
317
+ ? error.message
318
+ : typeof detail?.message === "string" && detail.message.length > 0
319
+ ? detail.message
320
+ : describeSocketError(error);
321
+ const message = sanitizeAgentFacingText(rawMessage).slice(0, 500);
322
+ const code = typeof detail?.code === "number" ? detail.code : undefined;
323
+ const status = typeof detail?.status === "number" ? detail.status : undefined;
324
+ const category = status === 408 || status === 504 || /timed out|timeout/i.test(message)
325
+ ? "timeout"
326
+ : status === 401 ||
327
+ status === 403 ||
328
+ code === 401 ||
329
+ code === 403 ||
330
+ /\b(?:401|403)\b|unauthoriz|forbidden/i.test(message)
331
+ ? "authentication"
332
+ : status === 409 || code === 409 || /\b409\b|conflict/i.test(message)
333
+ ? "conflict"
334
+ : "downstream";
335
+ return {
336
+ category,
337
+ ...(code === undefined ? {} : { code }),
338
+ message,
339
+ ...(status === undefined ? {} : { status }),
340
+ };
341
+ }
342
+ function normalizeCheckpointWarning(value) {
343
+ if (typeof value !== "object" || value === null) {
344
+ return undefined;
345
+ }
346
+ const warning = value;
347
+ if (warning.code !== "draft_state_cleanup_failed" ||
348
+ typeof warning.message !== "string") {
349
+ return undefined;
350
+ }
351
+ const message = sanitizeAgentFacingText(warning.message).trim().slice(0, 500);
352
+ if (!message) {
353
+ return undefined;
354
+ }
355
+ return {
356
+ code: "draft_state_cleanup_failed",
357
+ message,
358
+ };
359
+ }
360
+ /**
361
+ * What the socket said on its way out, appended to the disconnect reply.
362
+ *
363
+ * The close frame is the only account of why a turn died that reaches the
364
+ * caller: 1006 is a proxy or network kill that sent no frame at all, 1001 a
365
+ * sandbox going away, and an application code is a cause the dev server chose
366
+ * to name. This process's console is not visible to whoever asked for the
367
+ * build, so a bare "it disconnected" leaves them nothing to act on.
368
+ */
369
+ function describeClose(close) {
370
+ const parts = [];
371
+ if (close.code !== undefined) {
372
+ parts.push(`code ${close.code}`);
373
+ }
374
+ if (close.reason) {
375
+ parts.push(close.reason);
376
+ }
377
+ const closed = parts.length > 0 ? ` The session closed with ${parts.join(": ")}.` : "";
378
+ // A death that sends no close frame reports only 1006, which names nothing.
379
+ // The error that arrived just before it is the account of what happened.
380
+ return close.error ? `${closed} Socket error: ${close.error}.` : closed;
381
+ }
382
+ /**
383
+ * Whether the close is the live edit naming its own end.
384
+ *
385
+ * 4000-4999 is the WebSocket private range: nothing but the peer's own code
386
+ * puts a value there, so a close carrying one is a statement - a lost live-edit
387
+ * lock, a deliberate shutdown. Every other code belongs to the transport, and
388
+ * 1006 in particular is what a death with no close frame reports, which is
389
+ * equally what a reaped proxy connection reports. Those say the socket ended;
390
+ * only the application range says the live edit did.
391
+ */
392
+ function isApplicationClose(close) {
393
+ return close.code !== undefined && close.code >= 4000 && close.code <= 4999;
394
+ }
395
+ /**
396
+ * How the liveness beat was faring when the socket went, for the reply.
397
+ *
398
+ * Beats landing right up to the close mean the connection was carrying traffic
399
+ * and cannot have been reaped for idleness, which points at the sandbox rather
400
+ * than the path to it. A run of failures says the opposite. The close code
401
+ * distinguishes neither on its own.
402
+ *
403
+ * Several of these counts can stand at once, because only an answered beat
404
+ * clears the account: a refusal answers one beat and leaves every record of
405
+ * what went missing behind it. The order below is therefore most-specific
406
+ * first, and a stated cause outranks an inferred one - a refusal is the only
407
+ * outcome where the peer said why, so it is reported ahead of a silence that
408
+ * merely happened to look excusable.
409
+ */
410
+ function describeHeartbeat(heartbeat, at) {
411
+ return (describeSettledBeats(heartbeat, at) + describeOutstandingBeat(heartbeat, at));
412
+ }
413
+ /**
414
+ * The beat that never got an answer because the socket went first.
415
+ *
416
+ * Appended to whichever clause fired rather than being one of them: an
417
+ * outstanding beat is a fact about the end of the connection, and it stands
418
+ * alongside whatever the settled beats say. A refusal followed by silence is a
419
+ * different death from refusals arriving right up to the close, and the
420
+ * settled counts alone cannot tell those apart - only an answered beat clears
421
+ * them, and this beat was never answered at all.
422
+ *
423
+ * Sub-second waits say nothing and are left out. A beat is in flight for
424
+ * exactly as long as an answer takes, so a close landing inside a healthy round
425
+ * trip would otherwise report a silence of "0s" and indict the request channel
426
+ * for the very exchange that proves it works.
427
+ */
428
+ function describeOutstandingBeat(heartbeat, at) {
429
+ if (heartbeat.pendingSince === undefined) {
430
+ return "";
431
+ }
432
+ const waitingMs = at - heartbeat.pendingSince;
433
+ if (waitingMs < MIN_REPORTED_BEAT_WAIT_MS) {
434
+ return "";
435
+ }
436
+ const seconds = Math.round(waitingMs / 1000);
437
+ return ` A beat sent ${seconds}s earlier had still not been answered.`;
438
+ }
439
+ function describeSettledBeats(heartbeat, at) {
440
+ if (heartbeat.refusals > 0) {
441
+ const beats = heartbeat.refusals === 1 ? "beat" : "beats";
442
+ // The refusal's own reason rather than `lastError`: an unanswered beat
443
+ // after the refusal overwrites that with its own cause while leaving
444
+ // `refusals` standing, so reading it here would put a timeout's message in
445
+ // the peer's mouth.
446
+ const stated = heartbeat.lastRefusalError ?? heartbeat.lastError;
447
+ const cause = stated ? ` (${stated})` : "";
448
+ // Reported ahead of the silence counts, and alongside them rather than
449
+ // instead of them. A refusal is an answer: the connection worked and the
450
+ // peer named its own reason for the turn dying, which no inferred silence
451
+ // outranks. But a refusal clears nothing, so "refused, then went quiet" is
452
+ // a different death from "refusing right up to the close" and the caller
453
+ // needs both halves to tell them apart.
454
+ const silence = heartbeat.failures > 0
455
+ ? ` ${heartbeat.failures} later ${heartbeat.failures === 1 ? "beat" : "beats"} went unanswered.`
456
+ : "";
457
+ return ` ${heartbeat.refusals} liveness ${beats} came back refused${cause}.${silence}`;
458
+ }
459
+ if (heartbeat.failures > 0) {
460
+ const beats = heartbeat.failures === 1 ? "beat" : "beats";
461
+ const cause = heartbeat.lastError ? ` (${heartbeat.lastError})` : "";
462
+ return ` ${heartbeat.failures} liveness ${beats} failed before it${cause}.`;
463
+ }
464
+ if (heartbeat.lapses > 0) {
465
+ const beats = heartbeat.lapses === 1 ? "beat" : "beats";
466
+ // A silent request channel on a connection that was demonstrably carrying
467
+ // traffic. Reporting this as a plain failure would point at the network,
468
+ // which is the one thing it rules out.
469
+ return ` ${heartbeat.lapses} liveness ${beats} went unanswered while the socket was still delivering data.`;
470
+ }
471
+ if (heartbeat.lastSuccessAt !== undefined) {
472
+ const seconds = Math.round((at - heartbeat.lastSuccessAt) / 1000);
473
+ return ` The last liveness beat succeeded ${seconds}s earlier.`;
474
+ }
475
+ if (heartbeat.sent > 0) {
476
+ const beats = heartbeat.sent === 1 ? "beat" : "beats";
477
+ return ` ${heartbeat.sent} liveness ${beats} sent, none answered.`;
478
+ }
479
+ return " No liveness beat had run yet.";
480
+ }
481
+ function decisionFields(decision) {
482
+ return decision ? { decision } : {};
483
+ }
484
+ async function attempt(url, timeoutMs, onSocketError) {
485
+ const socket = new WebSocket(url, GATEWAY_PROTOCOL);
486
+ return await new Promise((resolve, reject) => {
487
+ const timer = setTimeout(() => {
488
+ socket.terminate();
489
+ reject(new Error("session is not accepting sockets yet"));
490
+ }, timeoutMs);
491
+ const settle = (outcome) => {
492
+ clearTimeout(timer);
493
+ socket.off("open", onOpen);
494
+ socket.off("error", onError);
495
+ outcome();
496
+ };
497
+ const onOpen = () => settle(() => {
498
+ // ISocket attaches its own handlers a tick later. An 'error' landing in
499
+ // that gap would have no listener, and node throws on an unhandled
500
+ // emitter 'error' rather than reporting it to the caller. Recording it
501
+ // rather than discarding it is what makes a later 1006 explainable.
502
+ socket.on("error", onSocketError);
503
+ resolve(socket);
504
+ });
505
+ const onError = (error) => settle(() => reject(error));
506
+ socket.once("open", onOpen);
507
+ socket.once("error", onError);
508
+ });
509
+ }
510
+ /**
511
+ * Waits for a lookup, but never past the deadline the caller is already bound
512
+ * by, and never raises.
513
+ *
514
+ * The connect loop clamps every socket attempt to what is left of its budget;
515
+ * a re-resolve is a Superblocks API round trip made from inside that loop and
516
+ * has to answer to the same clock. A hung one would otherwise hold the loop
517
+ * open for as long as it hangs, and `open` would return later than the
518
+ * deadline it promised by that much. Giving up on it leaves the host already
519
+ * in hand, which is the same fallback a failed lookup gets.
520
+ */
521
+ async function withinDeadline(lookup, deadline) {
522
+ const remaining = deadline - Date.now();
523
+ if (remaining <= 0) {
524
+ return undefined;
525
+ }
526
+ let timer;
527
+ try {
528
+ return await Promise.race([
529
+ lookup.catch(() => undefined),
530
+ new Promise((resolve) => {
531
+ timer = setTimeout(() => resolve(undefined), remaining);
532
+ }),
533
+ ]);
534
+ }
535
+ finally {
536
+ if (timer) {
537
+ clearTimeout(timer);
538
+ }
539
+ }
540
+ }
541
+ async function beforePromptDeadline(work, deadline, signal) {
542
+ if (signal?.aborted) {
543
+ throw new PromptNotSentError("Prompt forward cancelled before the live-edit request was sent", signal.reason);
544
+ }
545
+ const remaining = deadline - Date.now();
546
+ if (remaining <= 0) {
547
+ throw new PromptNotSentError("Prompt forward timed out before the live-edit request was sent");
548
+ }
549
+ let timer;
550
+ let onAbort;
551
+ try {
552
+ return await Promise.race([
553
+ work,
554
+ new Promise((_resolve, reject) => {
555
+ timer = setTimeout(() => reject(new PromptNotSentError("Prompt forward timed out before the live-edit request was sent")), remaining);
556
+ }),
557
+ ...(signal
558
+ ? [
559
+ new Promise((_resolve, reject) => {
560
+ onAbort = () => reject(new PromptNotSentError("Prompt forward cancelled before the live-edit request was sent", signal.reason));
561
+ signal.addEventListener("abort", onAbort, { once: true });
562
+ }),
563
+ ]
564
+ : []),
565
+ ]);
566
+ }
567
+ finally {
568
+ if (timer) {
569
+ clearTimeout(timer);
570
+ }
571
+ if (onAbort) {
572
+ signal?.removeEventListener("abort", onAbort);
573
+ }
574
+ }
575
+ }
576
+ async function open(url, attemptTimeoutMs, deadline, onSocketError,
577
+ /**
578
+ * Where the live edit is now, asked for only once and only when the host in
579
+ * hand is denying it. Absent keeps the prompt on the host it was given, and
580
+ * so does a hook that answers with nothing or fails: the host already in hand
581
+ * is the better fallback than abandoning the attempt.
582
+ */
583
+ nextHost) {
584
+ let current = url;
585
+ let lastError;
586
+ let consecutiveRejections = 0;
587
+ let asks = 0;
588
+ while (Date.now() < deadline) {
589
+ try {
590
+ return await attempt(current, Math.min(attemptTimeoutMs, deadline - Date.now()), onSocketError);
591
+ }
592
+ catch (error) {
593
+ lastError = error;
594
+ consecutiveRejections = isHostRejection(error)
595
+ ? consecutiveRejections + 1
596
+ : 0;
597
+ // Asked for only after the host has said no more than once: a single
598
+ // rejection can be an ingress route that exists a moment before the pod
599
+ // behind it does. Asked again only after another full streak, and never
600
+ // more than a few times per open.
601
+ //
602
+ // The spacing is the point, not politeness. The first ask lands about a
603
+ // second after the resolve that produced this host, often before SABS
604
+ // has observed the pod is gone, so it can hand back the same dead
605
+ // binding. Asking once and never again would spend the rest of the
606
+ // budget on a host already known to be denying the session.
607
+ if (nextHost &&
608
+ asks < MAX_RERESOLVES_PER_OPEN &&
609
+ consecutiveRejections >= HOST_REJECTIONS_BEFORE_RERESOLVE) {
610
+ asks += 1;
611
+ // Re-armed: the next ask waits for a fresh streak rather than
612
+ // following on the very next rejection.
613
+ consecutiveRejections = 0;
614
+ const moved = await withinDeadline(nextHost(), deadline);
615
+ if (moved && moved.href !== current.href) {
616
+ current = moved;
617
+ // Straight to the attempt: the delay below is for a host worth
618
+ // asking again, and this is a different one.
619
+ continue;
620
+ }
621
+ }
622
+ const remaining = deadline - Date.now();
623
+ if (remaining <= 0) {
624
+ break;
625
+ }
626
+ await new Promise((resolve) => setTimeout(resolve, Math.min(CONNECT_RETRY_DELAY_MS, remaining)));
627
+ }
628
+ }
629
+ throw new Error(`Prompt forward timed out: ${current.host} did not accept a live-edit socket (${lastError instanceof Error ? lastError.message : String(lastError)})${current.host !== url.host ? ` after moving from ${url.host}` : ""}`);
630
+ }
631
+ /**
632
+ * The typed `editor.*` surface for a held session.
633
+ *
634
+ * Built per call rather than held on the session because the edit-scoped token
635
+ * travels with the request, not with the socket: a session outlives the token
636
+ * a given turn was authorized with.
637
+ */
638
+ function editorSocket(held, sessionToken) {
639
+ return new EditorSocket(held.client, () => sessionToken ? `Bearer ${sessionToken}` : undefined);
640
+ }
641
+ export class WebSocketSessionPeer {
642
+ options;
643
+ held = new Map();
644
+ promptStarts = new Map();
645
+ /**
646
+ * Every server -> editor method the dev server has called on this peer.
647
+ * Diagnostic only: it answers "did that push ever arrive" without guessing
648
+ * from what the collector chose to keep.
649
+ */
650
+ inboundMethods = new Set();
651
+ /**
652
+ * In-flight opens, so overlapping prompts for one app share a socket instead
653
+ * of each opening their own and orphaning all but the last.
654
+ */
655
+ connecting = new Map();
656
+ /**
657
+ * Turns keyed by application. An entry outlives the call that started it and
658
+ * is kept after finishing so a late poll still learns the outcome; the next
659
+ * prompt for the same app replaces it.
660
+ */
661
+ turns = new Map();
662
+ /** Browser-owned turns observed over an authenticated held session. */
663
+ observedTurns = new Map();
664
+ editorStructureByScope = new Map();
665
+ /** Facts about an application that outlive any one turn. */
666
+ appState;
667
+ constructor(options = {}) {
668
+ this.options = options;
669
+ this.appState =
670
+ options.appState ??
671
+ new AppState({
672
+ ...(options.failedStartTtlMs === undefined
673
+ ? {}
674
+ : { failedStartTtlMs: options.failedStartTtlMs }),
675
+ ...(options.maxFailedStarts === undefined
676
+ ? {}
677
+ : { maxFailedStarts: options.maxFailedStarts }),
678
+ ...(options.pendingStartTtlMs === undefined
679
+ ? {}
680
+ : { pendingStartTtlMs: options.pendingStartTtlMs }),
681
+ });
682
+ }
683
+ /** The per-application facts this peer writes, for the capability layer. */
684
+ state() {
685
+ return this.appState;
686
+ }
687
+ recordPendingStart(input) {
688
+ return this.appState.recordPendingStart(input, {
689
+ ...(input.idempotencyKey === undefined
690
+ ? {}
691
+ : { idempotencyKey: input.idempotencyKey }),
692
+ ...(input.retryAction ? { retryAction: input.retryAction } : {}),
693
+ });
694
+ }
695
+ clearPendingStart(input) {
696
+ this.appState.clearPendingStart(input, input.idempotencyKey, undefined, input.pendingStartToken);
697
+ }
698
+ async waitForPendingTurn(input) {
699
+ for (;;) {
700
+ const turn = this.turns.get(input.applicationId);
701
+ if (turn &&
702
+ turn.revision > input.afterRevision &&
703
+ (input.idempotencyKey === undefined ||
704
+ turn.idempotencyKey === input.idempotencyKey) &&
705
+ (input.pendingStartToken === undefined ||
706
+ turn.pendingStartToken === input.pendingStartToken) &&
707
+ (input.scope.branch === undefined || input.scope.branch === turn.branch)) {
708
+ return turn;
709
+ }
710
+ if (input.signal?.aborted ||
711
+ !this.appState.pendingStart(input.scope) ||
712
+ Date.now() >= input.deadlineAtMs) {
713
+ return undefined;
714
+ }
715
+ await new Promise((resolve) => {
716
+ const settle = () => {
717
+ clearTimeout(timer);
718
+ input.signal?.removeEventListener("abort", settle);
719
+ resolve();
720
+ };
721
+ const timer = setTimeout(settle, Math.min(PENDING_START_POLL_INTERVAL_MS, Math.max(0, input.deadlineAtMs - Date.now())));
722
+ input.signal?.addEventListener("abort", settle, { once: true });
723
+ });
724
+ }
725
+ }
726
+ /**
727
+ * Plan waiting on human approve/refine for this application, if any.
728
+ */
729
+ pendingPlan(applicationId, requestedBranch) {
730
+ return this.appState.pendingPlan(applicationId, requestedBranch);
731
+ }
732
+ /**
733
+ * Full needs_decision payload waiting on a human for this application.
734
+ */
735
+ pendingDecision(applicationId, requestedBranch) {
736
+ return this.appState.pendingDecision(applicationId, requestedBranch);
737
+ }
738
+ /**
739
+ * Remember a turn that never reached Superblocks. See
740
+ * {@link SessionPeer.recordFailedStart}.
741
+ */
742
+ recordFailedStart(input) {
743
+ const pendingCleared = this.appState.clearPendingStart(input, input.idempotencyKey, undefined, input.pendingStartToken);
744
+ if (input.pendingStartRequired && !pendingCleared) {
745
+ return;
746
+ }
747
+ const running = this.turns.get(input.applicationId);
748
+ if (running &&
749
+ !running.finished &&
750
+ input.branch === running.branch &&
751
+ !(input.pendingStartRequired && pendingCleared)) {
752
+ // This failed prompt did not end the turn already running on its branch.
753
+ // Keeping its newer revision would hide that turn's eventual outcome.
754
+ console.warn("gateway clark turn never started, but one is running", {
755
+ applicationId: input.applicationId,
756
+ organizationId: input.organizationId,
757
+ reason: input.reason,
758
+ userId: input.userId,
759
+ });
760
+ return;
761
+ }
762
+ console.warn("gateway clark turn never started", {
763
+ applicationId: input.applicationId,
764
+ organizationId: input.organizationId,
765
+ reason: input.reason,
766
+ userId: input.userId,
767
+ });
768
+ this.appState.recordFailedStart(input, {
769
+ ...(input.decision ? { decision: input.decision } : {}),
770
+ generationStatus: input.generationStatus ?? "live_edit_terminated",
771
+ reply: input.reason,
772
+ ...(input.recoveryAction
773
+ ? { recoveryAction: input.recoveryAction }
774
+ : {}),
775
+ ...(input.retryAction ? { retryAction: input.retryAction } : {}),
776
+ ...(input.retryable === false ? { retryable: false } : {}),
777
+ // Nothing ran, so nothing is viewable. Reporting "ready" here is how a
778
+ // caller comes to show the untouched starter template as the app.
779
+ viewStatus: "unknown",
780
+ }, input.revision);
781
+ }
782
+ /**
783
+ * Where this application's turn stands for this caller, read without draining
784
+ * the activity an {@link WebSocketSessionPeer.awaitTurn} poll is owed.
785
+ *
786
+ * A running turn is current. Once it finishes, the newer of that turn and an
787
+ * unscoped failed start wins; explicit branch reads filter before comparing.
788
+ * Revisions preserve that ordering even when both events share a clock tick.
789
+ */
790
+ turnState(input) {
791
+ const storedTurn = this.latestTurn(input.applicationId);
792
+ const turn = storedTurn &&
793
+ input.branch !== undefined &&
794
+ input.branch !== storedTurn.branch
795
+ ? undefined
796
+ : storedTurn;
797
+ if (this.appState.pendingStart(input)) {
798
+ return { phase: "running" };
799
+ }
800
+ const failed = this.appState.failedStart(input, turn?.revision);
801
+ if (failed) {
802
+ return {
803
+ ...(failed.branch === undefined ? {} : { branch: failed.branch }),
804
+ generationStatus: failed.generationStatus,
805
+ phase: "finished",
806
+ ...(failed.recoveryAction
807
+ ? { recoveryAction: failed.recoveryAction }
808
+ : {}),
809
+ ...(failed.retryable === false ? { reply: failed.reply } : {}),
810
+ ...(failed.retryAction ? { retryAction: failed.retryAction } : {}),
811
+ ...(failed.retryable === false ? { retryable: false } : {}),
812
+ };
813
+ }
814
+ if (turn && !turn.finished) {
815
+ return {
816
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
817
+ phase: "running",
818
+ };
819
+ }
820
+ if (!turn?.finished) {
821
+ return undefined;
822
+ }
823
+ return {
824
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
825
+ ...(turn.finished.checkpointCommitId
826
+ ? { checkpointCommitId: turn.finished.checkpointCommitId }
827
+ : {}),
828
+ ...(turn.finished.checkpointFailure
829
+ ? { checkpointFailure: turn.finished.checkpointFailure }
830
+ : {}),
831
+ ...(turn.finished.checkpointWarning
832
+ ? { checkpointWarning: turn.finished.checkpointWarning }
833
+ : {}),
834
+ generationStatus: turn.finished.generationStatus,
835
+ phase: "finished",
836
+ ...(turn.retryAction ? { retryAction: turn.retryAction } : {}),
837
+ ...(turn.finished.retryable === false
838
+ ? { retryable: false }
839
+ : {}),
840
+ };
841
+ }
842
+ clearPendingPlan(applicationId) {
843
+ this.appState.clearPending(applicationId);
844
+ }
845
+ clearPendingDecision(applicationId) {
846
+ this.appState.clearPending(applicationId);
847
+ }
848
+ isHeld(applicationId) {
849
+ const entry = this.held.get(applicationId);
850
+ return Boolean(entry && isSocketOpen(entry.ws));
851
+ }
852
+ libraryBrowserContext(applicationId, requestedBranch) {
853
+ const held = this.held.get(applicationId);
854
+ if (!held || requestedBranch !== held.branchName) {
855
+ return undefined;
856
+ }
857
+ return this.buildHeldBrowserContext(held);
858
+ }
859
+ async holdLiveEdit(input) {
860
+ const segments = String(input.sessionToken ?? "").split(".").length;
861
+ if (segments !== 3) {
862
+ throw new Error(`Live-edit hold failed: the live-edit credential is not a JWT (${segments} segment(s)); the session will reject it`);
863
+ }
864
+ if (!input.userId.trim()) {
865
+ throw new Error("Live-edit hold failed: userId is required to identify who the session is held for");
866
+ }
867
+ const expectedTurn = this.turns.get(input.applicationId);
868
+ this.assertReadOnlyHoldCurrent(input.applicationId, expectedTurn);
869
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
870
+ const deadline = Date.now() + timeoutMs;
871
+ const held = await this.ensureHeld({
872
+ applicationId: input.applicationId,
873
+ branchName: input.branch,
874
+ keepalive: input.keepalive,
875
+ liveEdit: liveEditOf(input),
876
+ userId: input.userId,
877
+ }, deadline, expectedTurn);
878
+ if (input.apiKey) {
879
+ held.apiKey = input.apiKey;
880
+ }
881
+ if (input.organizationId) {
882
+ held.organizationId = input.organizationId;
883
+ }
884
+ held.sessionToken = input.sessionToken;
885
+ await this.resolveHeldProfileKey(held);
886
+ await this.resolveHeldOrchestrator(held);
887
+ }
888
+ release(applicationId) {
889
+ const entry = this.held.get(applicationId);
890
+ if (!entry) {
891
+ return;
892
+ }
893
+ this.drop(entry);
894
+ }
895
+ closeAll() {
896
+ for (const applicationId of [...this.held.keys()]) {
897
+ this.release(applicationId);
898
+ }
899
+ }
900
+ /**
901
+ * Sends the prompt and waits for Clark's whole turn.
902
+ *
903
+ * Kept for callers with nowhere to poll from — the HTTP capability routes
904
+ * answer a single request — and implemented on the same engine as
905
+ * {@link startPrompt} so there is one turn lifecycle, not two.
906
+ */
907
+ async forwardPrompt(input) {
908
+ const started = await this.startPrompt(input);
909
+ if (started.status === "auth_required") {
910
+ return started;
911
+ }
912
+ const turnTimeoutMs = this.options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
913
+ const readyWaitMs = this.options.readyWaitMs ?? DEFAULT_READY_WAIT_MS;
914
+ // The turn's own budget already bounds this; the extra slack covers the
915
+ // checkpoint that follows Clark's terminal state.
916
+ const deadline = Date.now() + turnTimeoutMs + readyWaitMs + 1_000;
917
+ for (;;) {
918
+ const update = await this.awaitTurn({
919
+ applicationId: input.applicationId,
920
+ branch: input.branch,
921
+ signal: input.signal,
922
+ waitMs: Math.max(0, deadline - Date.now()),
923
+ });
924
+ if (update.status === "finished") {
925
+ const { activity: _activity, elapsedMs: _elapsedMs, status: _status, ...finished } = update;
926
+ return {
927
+ ...finished,
928
+ // See startPrompt, which resolved this: the turn may have run on a
929
+ // live edit the caller never named.
930
+ ...(started.liveEditId ? { liveEditId: started.liveEditId } : {}),
931
+ status: "accepted",
932
+ };
933
+ }
934
+ if (update.status === "unknown" || Date.now() >= deadline) {
935
+ throw new Error("Prompt forward failed: the turn ended without reporting an outcome");
936
+ }
937
+ }
938
+ }
939
+ claimPromptLane(applicationId) {
940
+ const ready = this.promptStarts.get(applicationId) ?? Promise.resolve();
941
+ let release;
942
+ const hold = new Promise((resolve) => {
943
+ release = resolve;
944
+ });
945
+ const current = ready.then(() => hold);
946
+ this.promptStarts.set(applicationId, current);
947
+ void current.then(() => {
948
+ if (this.promptStarts.get(applicationId) === current) {
949
+ this.promptStarts.delete(applicationId);
950
+ }
951
+ });
952
+ return { ready, release: () => void ready.then(release) };
953
+ }
954
+ async startPrompt(input) {
955
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
956
+ const settleMs = this.options.settleMs ?? DEFAULT_SETTLE_MS;
957
+ // The session rejects a malformed credential with "Invalid token
958
+ // specified", which reads like the token expired rather than like the
959
+ // gateway never had one. Say which it is, without quoting the credential.
960
+ const segments = String(input.sessionToken ?? "").split(".").length;
961
+ if (segments !== 3) {
962
+ throw new PromptNotSentError(`Prompt forward failed: the live-edit credential is not a JWT (${segments} segment(s)); the session will reject it`);
963
+ }
964
+ if (!input.userId?.trim()) {
965
+ throw new PromptNotSentError("Prompt forward failed: userId is required to identify who the turn is running for");
966
+ }
967
+ const userId = input.userId;
968
+ const deadline = Date.now() + timeoutMs;
969
+ const revision = input.revision ?? this.appState.markTurnStarted();
970
+ const lane = this.claimPromptLane(input.applicationId);
971
+ let held;
972
+ try {
973
+ await beforePromptDeadline(lane.ready, deadline, input.signal);
974
+ try {
975
+ const requestedLiveEdit = liveEditOf(input);
976
+ const current = this.held.get(input.applicationId);
977
+ const liveEdit = current?.userId === userId &&
978
+ current.movedFrom === requestedLiveEdit.url &&
979
+ isSocketOpen(current.ws)
980
+ ? current.liveEdit
981
+ : requestedLiveEdit;
982
+ const heldInput = {
983
+ applicationId: input.applicationId,
984
+ branchName: input.branch,
985
+ keepalive: input.keepalive,
986
+ liveEdit,
987
+ ...(input.resolveLiveEdit
988
+ ? { resolveLiveEdit: input.resolveLiveEdit }
989
+ : {}),
990
+ userId,
991
+ };
992
+ const opening = this.connect(heldInput, deadline, false);
993
+ try {
994
+ held = await beforePromptDeadline(opening, deadline, input.signal);
995
+ if (liveEdit !== requestedLiveEdit) {
996
+ held.movedFrom = requestedLiveEdit.url;
997
+ }
998
+ if (current?.userId === userId &&
999
+ current.liveEdit.url === held.liveEdit.url) {
1000
+ held.syncStatus = current.syncStatus;
1001
+ }
1002
+ }
1003
+ catch (error) {
1004
+ void opening.then((stale) => {
1005
+ if (stale.provisional && !stale.dropping) {
1006
+ this.drop(stale);
1007
+ }
1008
+ }, () => undefined);
1009
+ throw error;
1010
+ }
1011
+ if (input.apiKey) {
1012
+ held.apiKey = input.apiKey;
1013
+ }
1014
+ if (input.organizationId) {
1015
+ held.organizationId = input.organizationId;
1016
+ }
1017
+ held.sessionToken = input.sessionToken;
1018
+ await beforePromptDeadline(this.resolveHeldProfileKey(held), deadline, input.signal);
1019
+ await beforePromptDeadline(this.resolveHeldOrchestrator(held), deadline, input.signal);
1020
+ }
1021
+ catch (error) {
1022
+ throw error instanceof PromptNotSentError
1023
+ ? error
1024
+ : new PromptNotSentError(describeSocketError(error), error);
1025
+ }
1026
+ let integrations = [];
1027
+ const editorContext = this.editorContextFor(held);
1028
+ if (editorContext) {
1029
+ try {
1030
+ integrations = await beforePromptDeadline(loadIntegrationsForPrompt(editorContext), deadline, input.signal);
1031
+ }
1032
+ catch (error) {
1033
+ if (error instanceof PromptNotSentError) {
1034
+ throw error;
1035
+ }
1036
+ console.warn("gateway failed to prefill prompt integrations", {
1037
+ applicationId: input.applicationId,
1038
+ message: error instanceof Error ? error.message : String(error),
1039
+ });
1040
+ }
1041
+ }
1042
+ await beforePromptDeadline(Promise.resolve(), deadline, input.signal);
1043
+ if (input.pendingStartRequired &&
1044
+ (input.organizationId === undefined ||
1045
+ input.idempotencyKey === undefined ||
1046
+ input.pendingStartToken === undefined ||
1047
+ !this.appState.pendingStart({
1048
+ applicationId: input.applicationId,
1049
+ ...(input.branch === undefined ? {} : { branch: input.branch }),
1050
+ organizationId: input.organizationId,
1051
+ userId,
1052
+ }, input.idempotencyKey, input.pendingStartToken))) {
1053
+ throw new PromptNotSentError("A newer start attempt replaced this one before its prompt was sent");
1054
+ }
1055
+ const turn = {
1056
+ abort: new AbortController(),
1057
+ activity: [],
1058
+ ...(held.branchName === undefined ? {} : { branch: held.branchName }),
1059
+ collector: undefined,
1060
+ delivered: 0,
1061
+ heardAtLastReconnect: 0,
1062
+ held,
1063
+ ...(input.idempotencyKey === undefined
1064
+ ? {}
1065
+ : { idempotencyKey: input.idempotencyKey }),
1066
+ listener: () => undefined,
1067
+ ...(input.pendingStartToken === undefined
1068
+ ? {}
1069
+ : { pendingStartToken: input.pendingStartToken }),
1070
+ recentRuns: [],
1071
+ reconnects: 0,
1072
+ revision,
1073
+ ...(input.attachments?.length ? { retryAction: "retry_build" } : {}),
1074
+ startedAt: Date.now(),
1075
+ turnId: randomUUID(),
1076
+ waiters: new Set(),
1077
+ };
1078
+ const report = (message) => {
1079
+ this.note(turn, message);
1080
+ input.onProgress?.(message);
1081
+ };
1082
+ const collector = new TurnCollector(report, {
1083
+ initialSyncStatus: held.syncStatus,
1084
+ settleMs,
1085
+ });
1086
+ const hasDefaultStructure = this.editorStructureByScope.get(editorStructureScopeKey(input.applicationId, held.branchName));
1087
+ if (hasDefaultStructure) {
1088
+ collector.handle({
1089
+ hasDefaultStructure,
1090
+ kind: "editor_structure",
1091
+ });
1092
+ }
1093
+ turn.collector = collector;
1094
+ const bufferedEvents = [];
1095
+ let accepted = false;
1096
+ const listener = (event) => {
1097
+ if (accepted) {
1098
+ collector.handle(event);
1099
+ }
1100
+ else {
1101
+ bufferedEvents.push(event);
1102
+ }
1103
+ };
1104
+ turn.listener = listener;
1105
+ held.pendingTurn = turn;
1106
+ held.listeners.add(listener);
1107
+ if (held.provisional) {
1108
+ held.heartbeat.start();
1109
+ }
1110
+ return await this.sendPrompt({
1111
+ acceptBufferedEvents: (generationId) => {
1112
+ collector.nameGeneration(generationId);
1113
+ accepted = true;
1114
+ for (const event of bufferedEvents) {
1115
+ collector.handle(event);
1116
+ }
1117
+ bufferedEvents.length = 0;
1118
+ },
1119
+ held,
1120
+ input,
1121
+ integrations,
1122
+ provisional: Boolean(held.provisional),
1123
+ turn,
1124
+ });
1125
+ }
1126
+ catch (error) {
1127
+ if (held?.provisional && !held.dropping) {
1128
+ this.drop(held);
1129
+ }
1130
+ throw error;
1131
+ }
1132
+ finally {
1133
+ lane.release();
1134
+ }
1135
+ }
1136
+ /**
1137
+ * Sends the prompt on a session already holding a subscribed turn, and hands
1138
+ * the turn to {@link runTurn} once the session confirms it took it on.
1139
+ */
1140
+ async sendPrompt(args) {
1141
+ const { held, input, integrations, provisional, turn } = args;
1142
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1143
+ const browserContext = this.browserContextFor(held);
1144
+ let acknowledgment;
1145
+ try {
1146
+ // The editor and the smoke client both send the credential twice: once
1147
+ // as the RPC payload's `jwt` and once as the ISocket Authorization
1148
+ // header. The session verifies the header; the AI service re-reads the
1149
+ // payload. Dropping either breaks a different hop.
1150
+ acknowledgment = await editorSocket(held, input.sessionToken).aiGenerateWithQueue({
1151
+ ...(browserContext ? { browserContext } : {}),
1152
+ jwt: input.sessionToken,
1153
+ llmConfig: {
1154
+ coreModel: this.options.coreModel ?? DEFAULT_LLM.coreModel,
1155
+ disabledTools: ["build_captureScreenshot", "build_navigatePreview"],
1156
+ // The ai-service gates Clark's testing tools on this flag *and* a
1157
+ // browser context, so the two travel together or not at all.
1158
+ ...(browserContext ? { flags: { isTestingEnabled: true } } : {}),
1159
+ interleavedThinking: false,
1160
+ provider: this.options.provider ?? DEFAULT_LLM.provider,
1161
+ thinkingBudgetTokens: "0",
1162
+ thinkingEnabled: false,
1163
+ unifiedProviderEnabled: true,
1164
+ },
1165
+ mode: (input.mode ?? "PLAN"),
1166
+ prompt: input.prompt,
1167
+ promptContext: {
1168
+ attachments: input.attachments ?? [],
1169
+ entities: [],
1170
+ // Sent without `metadata` on purpose: it is the expensive
1171
+ // per-integration detail, and Clark asks for it per integration
1172
+ // through `aiGetIntegration`. The shared request type does not
1173
+ // model that yet, so the narrowing is stated here rather than
1174
+ // widening every field of the payload.
1175
+ integrations: integrations,
1176
+ },
1177
+ ...(input.planContext
1178
+ ? {
1179
+ planContext: {
1180
+ ...(input.planContext.approved !== undefined
1181
+ ? { approved: input.planContext.approved }
1182
+ : {}),
1183
+ ...(input.planContext.plan
1184
+ ? { plan: input.planContext.plan }
1185
+ : {}),
1186
+ ...(input.planContext.title
1187
+ ? { title: input.planContext.title }
1188
+ : {}),
1189
+ },
1190
+ }
1191
+ : {}),
1192
+ ...(input.responseMetadata
1193
+ ? { responseMetadata: input.responseMetadata }
1194
+ : {}),
1195
+ queueMode: "interrupt",
1196
+ requestId: turn.turnId,
1197
+ userPreferences: { suggestNextSteps: false },
1198
+ });
1199
+ this.assertHeadlessTurnBound({ acknowledgment, turn });
1200
+ this.appState.clearLiveEditRecovery(input.applicationId);
1201
+ }
1202
+ catch (error) {
1203
+ this.discardUnstartedTurn(turn);
1204
+ const rejection = error;
1205
+ if (isAuthRejection(rejection)) {
1206
+ if (provisional) {
1207
+ this.drop(held);
1208
+ }
1209
+ else {
1210
+ this.armIdle(held);
1211
+ this.armKeepalive(held);
1212
+ }
1213
+ return {
1214
+ message: "Interactive auth required for datasource access",
1215
+ status: "auth_required",
1216
+ };
1217
+ }
1218
+ if (isRemoteRejection(rejection)) {
1219
+ if (provisional) {
1220
+ this.drop(held);
1221
+ }
1222
+ else {
1223
+ this.armIdle(held);
1224
+ this.armKeepalive(held);
1225
+ }
1226
+ throw new PromptNotSentError(`Prompt forward failed: ${rejection.message ?? "unknown socket error"}`, error);
1227
+ }
1228
+ else {
1229
+ const priorHeld = this.held.get(input.applicationId);
1230
+ this.supersedeTurn(input.applicationId);
1231
+ if (priorHeld) {
1232
+ this.drop(priorHeld);
1233
+ }
1234
+ if (held !== priorHeld) {
1235
+ this.drop(held);
1236
+ }
1237
+ }
1238
+ if (isTimeout(rejection)) {
1239
+ const healthy = await liveEditIsHealthy({
1240
+ applicationId: input.applicationId,
1241
+ liveEditUrl: held.liveEdit.url,
1242
+ sessionToken: held.sessionToken,
1243
+ });
1244
+ if (healthy && input.resolveLiveEdit) {
1245
+ recordGatewayFault({
1246
+ fatal: false,
1247
+ kind: "live_edit_queue_wedged",
1248
+ });
1249
+ if (!this.appState.claimLiveEditRecovery(input.applicationId)) {
1250
+ throw new PromptNotSentError("The replacement live-edit session also stopped responding before a prompt was accepted. Start a new application instead of retrying this build again.", error, {
1251
+ recoveryAction: "start_new_app",
1252
+ retryable: false,
1253
+ });
1254
+ }
1255
+ try {
1256
+ await input.terminateLiveEdit?.();
1257
+ }
1258
+ catch (terminationError) {
1259
+ this.appState.clearLiveEditRecovery(input.applicationId);
1260
+ throw new PromptNotSentError("The wedged live-edit session could not be disconnected. Send the same instruction again to retry recovery.", terminationError);
1261
+ }
1262
+ try {
1263
+ await input.resolveLiveEdit();
1264
+ }
1265
+ catch (resolveError) {
1266
+ throw new PromptNotSentError("The wedged live-edit session was disconnected, but its replacement could not be resolved. Send the same instruction again to continue recovery.", resolveError);
1267
+ }
1268
+ throw new PromptNotSentError("The live-edit session was replaced after its prompt queue stopped responding. Send the same instruction again to continue on the replacement session.", error);
1269
+ }
1270
+ throw new Error(`Prompt forward timed out: the live-edit session accepted the connection but did not acknowledge the prompt within ${timeoutMs}ms (${rejection.message}). The sandbox is usually still waking up when this happens, so retrying the same prompt normally works.`);
1271
+ }
1272
+ throw error instanceof Error
1273
+ ? error
1274
+ : new Error(`Prompt forward failed: ${rejection.message ?? "unknown socket error"}`);
1275
+ }
1276
+ turn.revision = this.appState.markTurnStarted();
1277
+ args.acceptBufferedEvents(acknowledgment?.generationId);
1278
+ input.onProgress?.("Superblocks accepted the prompt");
1279
+ // Only now, with the prompt accepted and a live turn about to go in the
1280
+ // map, is a recorded dead start for this branch out of date. Clearing it
1281
+ // when the slot was claimed instead dropped it before Superblocks had taken
1282
+ // anything, so a retry that came back needing interactive auth - a return,
1283
+ // not a throw, so nothing re-recorded it - left the app with no turn at all
1284
+ // and `get_app` went back to previewing the starter template.
1285
+ this.appState.clearFailedStarts(input.applicationId, turn.branch, turn.revision);
1286
+ this.appState.clearPendingStartsThrough(input.applicationId, turn.revision);
1287
+ // The pending decision goes for the same reason and at the same moment.
1288
+ // Clearing it when the slot was claimed spent the plan on a prompt
1289
+ // Superblocks had not taken: an approve that came back needing interactive
1290
+ // auth left neither a pending plan nor the BUILD latch - which is only set
1291
+ // once a start is accepted - so the retry proposed a plan again instead of
1292
+ // building the one the user had already approved.
1293
+ this.clearPendingDecision(input.applicationId);
1294
+ this.forgetObservedTurn(input.applicationId);
1295
+ const priorHeld = this.held.get(input.applicationId);
1296
+ this.supersedeTurn(input.applicationId);
1297
+ this.turns.set(input.applicationId, turn);
1298
+ delete held.pendingTurn;
1299
+ if (provisional) {
1300
+ delete held.provisional;
1301
+ this.setHeld(held);
1302
+ held.heartbeat.start();
1303
+ if (priorHeld && priorHeld !== held) {
1304
+ this.drop(priorHeld);
1305
+ }
1306
+ }
1307
+ // Deliberately not awaited: the turn outlives the call that started it so a
1308
+ // client with no progress channel can follow it through awaitTurn.
1309
+ void this.runTurn({
1310
+ applicationId: input.applicationId,
1311
+ onProgress: input.onProgress,
1312
+ sessionToken: input.sessionToken,
1313
+ turn,
1314
+ });
1315
+ return {
1316
+ activity: this.drain(turn),
1317
+ // Where the prompt ended up, which is not always where it was sent: a
1318
+ // connect that followed a moved session is holding an id the caller's
1319
+ // own resolve never saw, and this turn's events arrive under that one.
1320
+ ...(held.liveEdit.id ? { liveEditId: held.liveEdit.id } : {}),
1321
+ status: "accepted",
1322
+ };
1323
+ }
1324
+ latestTurn(applicationId) {
1325
+ return (this.observedTurns.get(applicationId) ?? this.turns.get(applicationId));
1326
+ }
1327
+ observeEditorEvent(held, event) {
1328
+ if (held.dropping || this.held.get(held.applicationId) !== held) {
1329
+ return;
1330
+ }
1331
+ let observed = this.observedTurns.get(held.applicationId);
1332
+ const message = event.kind === "message" ? event.message : undefined;
1333
+ const userContent = message?.role === "user" &&
1334
+ typeof message.content === "string" &&
1335
+ message.content.trim()
1336
+ ? message.content.trim()
1337
+ : undefined;
1338
+ const userMessageId = userContent && typeof message?.id === "string" ? message.id : undefined;
1339
+ if (userMessageId && observed?.userMessageIds.has(userMessageId)) {
1340
+ return;
1341
+ }
1342
+ const activeGeneration = event.kind === "generation_state" &&
1343
+ (event.state === "debugging" ||
1344
+ event.state === "generating" ||
1345
+ event.state === "summarizing");
1346
+ if (userContent || activeGeneration) {
1347
+ this.clearPendingDecision(held.applicationId);
1348
+ }
1349
+ const owned = this.turns.get(held.applicationId);
1350
+ if (owned) {
1351
+ if (!owned.finished && !userContent && !activeGeneration) {
1352
+ return;
1353
+ }
1354
+ this.supersedeTurn(held.applicationId);
1355
+ }
1356
+ const distinctGeneration = activeGeneration &&
1357
+ event.kind === "generation_state" &&
1358
+ Boolean(event.generationId &&
1359
+ observed?.generationId &&
1360
+ event.generationId !== observed.generationId);
1361
+ if (observed &&
1362
+ (observed.held !== held ||
1363
+ distinctGeneration ||
1364
+ (userContent && observed.finished))) {
1365
+ this.forgetObservedTurn(held.applicationId);
1366
+ observed = undefined;
1367
+ this.clearPendingDecision(held.applicationId);
1368
+ }
1369
+ if (observed?.finished) {
1370
+ if (!activeGeneration) {
1371
+ return;
1372
+ }
1373
+ if (event.kind === "generation_state" &&
1374
+ event.generationId &&
1375
+ event.generationId === observed.generationId &&
1376
+ observed.finished.generationStatus !== "needs_decision") {
1377
+ return;
1378
+ }
1379
+ observed = undefined;
1380
+ this.clearPendingDecision(held.applicationId);
1381
+ }
1382
+ if (!observed) {
1383
+ this.clearIdleTimer(held);
1384
+ const activity = [];
1385
+ const next = {
1386
+ abort: new AbortController(),
1387
+ activity,
1388
+ ...(held.branchName === undefined ? {} : { branch: held.branchName }),
1389
+ collector: new TurnCollector((message) => this.note(next, message), {
1390
+ ...(event.kind === "generation_state" && event.generationId
1391
+ ? { generationId: event.generationId }
1392
+ : {}),
1393
+ settleMs: this.options.settleMs ?? DEFAULT_SETTLE_MS,
1394
+ }),
1395
+ delivered: 0,
1396
+ ...(event.kind === "generation_state" && event.generationId
1397
+ ? { generationId: event.generationId }
1398
+ : {}),
1399
+ held,
1400
+ recentRuns: [],
1401
+ revision: this.appState.markTurnStarted(),
1402
+ startedAt: Date.now(),
1403
+ userMessageIds: new Set(),
1404
+ waiters: new Set(),
1405
+ };
1406
+ observed = next;
1407
+ this.observedTurns.set(held.applicationId, observed);
1408
+ void this.finishObservedTurn(held.applicationId, observed);
1409
+ }
1410
+ if (userMessageId) {
1411
+ observed.userMessageIds.add(userMessageId);
1412
+ }
1413
+ if (event.kind === "generation_state" &&
1414
+ event.generationId &&
1415
+ !observed.generationId) {
1416
+ observed.generationId = event.generationId;
1417
+ observed.collector.nameGeneration(event.generationId);
1418
+ }
1419
+ if (userContent) {
1420
+ this.note(observed, `User in editor: ${userContent}`);
1421
+ }
1422
+ observed.collector.handle(event);
1423
+ }
1424
+ forgetObservedTurn(applicationId) {
1425
+ const observed = this.observedTurns.get(applicationId);
1426
+ if (!observed) {
1427
+ return;
1428
+ }
1429
+ this.observedTurns.delete(applicationId);
1430
+ this.settleObservedTurn(observed, "cancelled");
1431
+ }
1432
+ async finishObservedTurn(applicationId, turn) {
1433
+ let generationStatus;
1434
+ try {
1435
+ generationStatus = await turn.collector.waitUntilTerminal({
1436
+ signal: turn.abort.signal,
1437
+ timeoutMs: this.options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
1438
+ });
1439
+ }
1440
+ catch {
1441
+ return;
1442
+ }
1443
+ if (this.observedTurns.get(applicationId) !== turn || turn.finished) {
1444
+ return;
1445
+ }
1446
+ const decision = turn.collector.decision();
1447
+ if (decision &&
1448
+ (generationStatus === "needs_decision" ||
1449
+ decision.kind === "missing_integrations")) {
1450
+ const reply = turn.collector.reply();
1451
+ this.appState.setPendingDecision(applicationId, {
1452
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1453
+ decision,
1454
+ reply,
1455
+ });
1456
+ if (decision.kind === "plan") {
1457
+ this.appState.setPendingPlan(applicationId, {
1458
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1459
+ ...(decision.messageId ? { messageId: decision.messageId } : {}),
1460
+ plan: decision.plan,
1461
+ ...(decision.title ? { title: decision.title } : {}),
1462
+ });
1463
+ }
1464
+ else {
1465
+ this.appState.clearPendingPlan(applicationId);
1466
+ }
1467
+ }
1468
+ turn.finished = {
1469
+ reply: turn.collector.reply(),
1470
+ generationStatus,
1471
+ viewStatus: turn.collector.viewableStatus(),
1472
+ ...decisionFields(decision),
1473
+ };
1474
+ turn.collector.dispose();
1475
+ this.wake(turn);
1476
+ this.armObservedTurnRelease(applicationId, turn);
1477
+ }
1478
+ settleObservedTurn(turn, generationStatus, reply = turn.collector.reply()) {
1479
+ if (turn.finished) {
1480
+ return;
1481
+ }
1482
+ turn.finished = {
1483
+ reply,
1484
+ generationStatus,
1485
+ viewStatus: turn.collector.viewableStatus(),
1486
+ ...decisionFields(turn.collector.decision()),
1487
+ };
1488
+ turn.collector.dispose();
1489
+ turn.abort.abort();
1490
+ this.wake(turn);
1491
+ }
1492
+ armObservedTurnRelease(applicationId, turn) {
1493
+ if (this.observedTurns.get(applicationId) !== turn ||
1494
+ this.held.get(applicationId) !== turn.held ||
1495
+ !turn.finished) {
1496
+ return;
1497
+ }
1498
+ if (turn.finished.generationStatus === "needs_decision") {
1499
+ this.armIdle(turn.held);
1500
+ }
1501
+ else {
1502
+ this.armObservedPostTurnRelease(turn);
1503
+ }
1504
+ this.armKeepalive(turn.held);
1505
+ }
1506
+ armObservedPostTurnRelease(turn) {
1507
+ const held = turn.held;
1508
+ this.clearTimers(held);
1509
+ const delayMs = this.options.releaseAfterTurnMs ?? DEFAULT_RELEASE_AFTER_TURN_MS;
1510
+ held.idleTimer = setTimeout(() => {
1511
+ if (this.observedTurns.get(held.applicationId) === turn &&
1512
+ this.held.get(held.applicationId) === held) {
1513
+ this.drop(held, { preserveFinishedObserved: true });
1514
+ }
1515
+ }, delayMs);
1516
+ held.idleTimer.unref?.();
1517
+ }
1518
+ async awaitTurn(input) {
1519
+ const pollStartedAtMs = Date.now();
1520
+ // The deadline is the end of the poll. Answers with activity or nothing.
1521
+ const deadlineAtMs = pollStartedAtMs + Math.max(0, input.waitMs);
1522
+ // The floor is the minimum time the poll will wait to collect activity for before it answers.
1523
+ // If the turn is finished (ex: needs a user decision), it will answer immediately.
1524
+ const floorAtMs = pollStartedAtMs +
1525
+ Math.min(Math.max(0, input.floorMs ?? 0), Math.max(0, input.waitMs));
1526
+ const storedTurn = this.latestTurn(input.applicationId);
1527
+ // Filter out turns that are not for the requested branch.
1528
+ let turn = storedTurn &&
1529
+ input.branch !== undefined &&
1530
+ input.branch !== storedTurn.branch
1531
+ ? undefined
1532
+ : storedTurn;
1533
+ // check if there is a pending turn for the requested branch.
1534
+ const pendingScope = input.organizationId && input.userId
1535
+ ? {
1536
+ applicationId: input.applicationId,
1537
+ ...(input.branch === undefined ? {} : { branch: input.branch }),
1538
+ organizationId: input.organizationId,
1539
+ userId: input.userId,
1540
+ }
1541
+ : undefined;
1542
+ const pending = pendingScope
1543
+ ? this.appState.pendingStart(pendingScope)
1544
+ : undefined;
1545
+ if (pendingScope && pending) {
1546
+ const started = await this.waitForPendingTurn({
1547
+ afterRevision: turn?.revision ?? 0,
1548
+ applicationId: input.applicationId,
1549
+ ...(pending.idempotencyKey === undefined
1550
+ ? {}
1551
+ : { idempotencyKey: pending.idempotencyKey }),
1552
+ pendingStartToken: pending.token,
1553
+ scope: pendingScope,
1554
+ signal: input.signal,
1555
+ deadlineAtMs,
1556
+ });
1557
+ if (started) {
1558
+ turn = started;
1559
+ }
1560
+ else if (this.appState.pendingStart(pendingScope)) {
1561
+ return { activity: [], elapsedMs: 0, status: "running" };
1562
+ }
1563
+ else {
1564
+ const currentTurn = this.latestTurn(input.applicationId);
1565
+ turn =
1566
+ currentTurn &&
1567
+ input.branch !== undefined &&
1568
+ input.branch !== currentTurn.branch
1569
+ ? undefined
1570
+ : currentTurn;
1571
+ }
1572
+ }
1573
+ // The same recency order as turnState. A poll that cannot say who it is for
1574
+ // reads no failed-start record at all; those records belong to callers.
1575
+ const failed = input.organizationId && input.userId
1576
+ ? this.appState.failedStart({
1577
+ applicationId: input.applicationId,
1578
+ ...(input.branch === undefined ? {} : { branch: input.branch }),
1579
+ organizationId: input.organizationId,
1580
+ userId: input.userId,
1581
+ }, turn?.revision)
1582
+ : undefined;
1583
+ // A turn that never started took no time, and there is no activity to
1584
+ // relay. Reporting it as finished is what stops the caller polling a session
1585
+ // that was never there.
1586
+ if (failed) {
1587
+ return { activity: [], elapsedMs: 0, status: "finished", ...failed };
1588
+ }
1589
+ if (!turn) {
1590
+ if (pendingScope && this.appState.pendingStart(pendingScope)) {
1591
+ return { activity: [], elapsedMs: 0, status: "running" };
1592
+ }
1593
+ return { activity: [], elapsedMs: 0, status: "unknown" };
1594
+ }
1595
+ const remainingWaitMs = Math.max(0, deadlineAtMs - Date.now());
1596
+ if (
1597
+ // If the turn is finished answer immediately.
1598
+ !turn.finished &&
1599
+ (remainingWaitMs > 0 || input.waitMs === 0) &&
1600
+ // Even if there is activity, we will wait until the floor is closed to answer.
1601
+ (turn.delivered >= turn.activity.length || Date.now() < floorAtMs)) {
1602
+ await new Promise((resolve) => {
1603
+ // Ends the wait and hands the caller whatever the turn has by now.
1604
+ const settle = () => {
1605
+ clearTimeout(deadlineTimer);
1606
+ clearTimeout(floorTimer);
1607
+ turn.waiters.delete(onWake);
1608
+ input.signal?.removeEventListener("abort", settle);
1609
+ resolve();
1610
+ };
1611
+ // Called on every wake (any activity)
1612
+ const onWake = () => {
1613
+ // Settle if the turn is finished or the floor is reached.
1614
+ if (turn.finished || Date.now() >= floorAtMs) {
1615
+ settle();
1616
+ }
1617
+ };
1618
+ // Settle if there is activity.
1619
+ // Called when the floor timer expires.
1620
+ const onFloor = () => {
1621
+ if (turn.delivered < turn.activity.length) {
1622
+ settle();
1623
+ }
1624
+ };
1625
+ const deadlineTimer = setTimeout(settle, remainingWaitMs);
1626
+ const floorTimer = setTimeout(onFloor, Math.max(0, floorAtMs - Date.now()));
1627
+ turn.waiters.add(onWake);
1628
+ input.signal?.addEventListener("abort", settle, { once: true });
1629
+ });
1630
+ }
1631
+ // Now drain the turn and return the activity.
1632
+ const activity = this.drain(turn);
1633
+ const elapsedMs = Date.now() - turn.startedAt;
1634
+ const recentRuns = turn.recentRuns.length > 0 ? [...turn.recentRuns] : undefined;
1635
+ if (turn.finished) {
1636
+ return {
1637
+ activity,
1638
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1639
+ elapsedMs,
1640
+ status: "finished",
1641
+ ...turn.finished,
1642
+ viewStatus: this.currentViewStatus(input.applicationId, turn.branch, turn.finished.viewStatus),
1643
+ ...(turn.retryAction ? { retryAction: turn.retryAction } : {}),
1644
+ ...(recentRuns ? { recentRuns } : {}),
1645
+ };
1646
+ }
1647
+ return {
1648
+ activity,
1649
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1650
+ elapsedMs,
1651
+ status: "running",
1652
+ ...(recentRuns ? { recentRuns } : {}),
1653
+ viewStatus: this.currentViewStatus(input.applicationId, turn.branch, turn.collector.viewableStatus()),
1654
+ };
1655
+ }
1656
+ /**
1657
+ * Follows the turn to its end on the held session: terminal state, then the
1658
+ * checkpoint that commits Clark's draft. Never throws — a caller polling for
1659
+ * news has to be told what happened, and the only place left to report a
1660
+ * failure is the turn record itself.
1661
+ */
1662
+ async runTurn(input) {
1663
+ const turn = input.turn;
1664
+ const { collector } = turn;
1665
+ const turnTimeoutMs = this.options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
1666
+ const readyWaitMs = this.options.readyWaitMs ?? DEFAULT_READY_WAIT_MS;
1667
+ const report = (message) => {
1668
+ this.note(turn, message);
1669
+ input.onProgress?.(message);
1670
+ };
1671
+ // The session the turn opened on, kept only so its listener is detached at
1672
+ // the end even if the turn moved off it in between.
1673
+ const opening = turn.held;
1674
+ let checkpointOutcome;
1675
+ try {
1676
+ if (turn.finished) {
1677
+ return;
1678
+ }
1679
+ if (!(await collector.waitForClarkPush({
1680
+ signal: turn.abort.signal,
1681
+ timeoutMs: this.pushWaitMs(),
1682
+ }))) {
1683
+ if (turn.finished) {
1684
+ return;
1685
+ }
1686
+ await this.reattach({ report, turn });
1687
+ if (turn.finished) {
1688
+ return;
1689
+ }
1690
+ if (!(await collector.waitForClarkPush({
1691
+ signal: turn.abort.signal,
1692
+ timeoutMs: this.options.reattachWaitMs ?? DEFAULT_REATTACH_WAIT_MS,
1693
+ }))) {
1694
+ if (turn.finished) {
1695
+ return;
1696
+ }
1697
+ console.warn("gateway clark turn detached", {
1698
+ applicationId: input.applicationId,
1699
+ inboundMethods: [...this.inboundMethods].sort(),
1700
+ });
1701
+ turn.finished ??= {
1702
+ reply: collector.reply(),
1703
+ generationStatus: "detached",
1704
+ viewStatus: collector.viewableStatus(),
1705
+ ...decisionFields(collector.decision()),
1706
+ };
1707
+ return;
1708
+ }
1709
+ }
1710
+ if (turn.finished) {
1711
+ return;
1712
+ }
1713
+ const generationStatus = await collector.waitUntilTerminal({
1714
+ signal: turn.abort.signal,
1715
+ timeoutMs: turnTimeoutMs,
1716
+ });
1717
+ if (turn.finished) {
1718
+ return;
1719
+ }
1720
+ console.info("gateway clark turn states", {
1721
+ applicationId: input.applicationId,
1722
+ draftPending: collector.needsDraftAccept(),
1723
+ generationStatus,
1724
+ inboundMethods: [...this.inboundMethods].sort(),
1725
+ states: collector.stateSequence(),
1726
+ workSignals: collector.workSignals(),
1727
+ });
1728
+ // Any turn that produced work asks for the accept, not only one still
1729
+ // holding `hasDraft: true` when it ends. A build normally does report a
1730
+ // draft, but the dev server sometimes flushes the files itself first, and
1731
+ // `aiAcceptDraft` is what mints the checkpoint either way. Accepting with
1732
+ // nothing pending is a no-op that returns no commit id. A turn that
1733
+ // produced nothing is `no_changes` and never reaches here, so Clark
1734
+ // answering a question in chat cannot checkpoint the bare template.
1735
+ if (generationStatus === "completed") {
1736
+ const lane = this.claimPromptLane(input.applicationId);
1737
+ await lane.ready;
1738
+ try {
1739
+ if (turn.finished || this.turns.get(input.applicationId) !== turn) {
1740
+ return;
1741
+ }
1742
+ // TODO(ENG-5922): `turn.held` is a dead session when the turn was kept
1743
+ // alive on the relay, so this accept cannot land and the caller gets a
1744
+ // finished turn with no commit.
1745
+ checkpointOutcome = await this.checkpoint({
1746
+ collector,
1747
+ held: turn.held,
1748
+ onProgress: report,
1749
+ readyWaitMs,
1750
+ sessionToken: input.sessionToken,
1751
+ turnId: turn.turnId,
1752
+ });
1753
+ }
1754
+ finally {
1755
+ lane.release();
1756
+ }
1757
+ }
1758
+ if (turn.finished || this.turns.get(input.applicationId) !== turn) {
1759
+ return;
1760
+ }
1761
+ if (generationStatus === "completed") {
1762
+ this.clearPendingDecision(input.applicationId);
1763
+ }
1764
+ const decision = collector.decision();
1765
+ if (decision &&
1766
+ (generationStatus === "needs_decision" ||
1767
+ decision.kind === "missing_integrations")) {
1768
+ const reply = collector.reply();
1769
+ this.appState.setPendingDecision(input.applicationId, {
1770
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1771
+ decision,
1772
+ ...(turn.recentRuns.length > 0
1773
+ ? { recentRuns: [...turn.recentRuns] }
1774
+ : {}),
1775
+ reply,
1776
+ });
1777
+ if (decision.kind === "plan") {
1778
+ this.appState.setPendingPlan(input.applicationId, {
1779
+ ...(turn.branch === undefined ? {} : { branch: turn.branch }),
1780
+ ...(decision.messageId ? { messageId: decision.messageId } : {}),
1781
+ plan: decision.plan,
1782
+ ...(decision.title ? { title: decision.title } : {}),
1783
+ });
1784
+ }
1785
+ else {
1786
+ this.appState.clearPendingPlan(input.applicationId);
1787
+ }
1788
+ }
1789
+ // Disconnect / supersede already wrote the authoritative outcome; never
1790
+ // overwrite that with a later collector timeout or terminal status.
1791
+ turn.finished ??= {
1792
+ ...(checkpointOutcome?.status === "saved" && checkpointOutcome.commitId
1793
+ ? { checkpointCommitId: checkpointOutcome.commitId }
1794
+ : {}),
1795
+ ...(checkpointOutcome?.status === "failed"
1796
+ ? { checkpointFailure: checkpointOutcome.failure }
1797
+ : {}),
1798
+ ...(checkpointOutcome?.status === "saved" && checkpointOutcome.warning
1799
+ ? { checkpointWarning: checkpointOutcome.warning }
1800
+ : {}),
1801
+ reply: collector.reply(),
1802
+ generationStatus,
1803
+ ...(turn.recentRuns.length > 0
1804
+ ? { recentRuns: [...turn.recentRuns] }
1805
+ : {}),
1806
+ viewStatus: collector.viewableStatus(),
1807
+ ...decisionFields(decision),
1808
+ };
1809
+ }
1810
+ catch (error) {
1811
+ if (!turn.finished) {
1812
+ console.warn("gateway clark turn failed", {
1813
+ applicationId: input.applicationId,
1814
+ message: error instanceof Error ? error.message : String(error),
1815
+ });
1816
+ }
1817
+ // A disconnect / supersede already settled the turn with the reason; the
1818
+ // abort it caused says less than what is already recorded.
1819
+ turn.finished ??= {
1820
+ reply: collector.reply(),
1821
+ generationStatus: "timeout",
1822
+ viewStatus: collector.viewableStatus(),
1823
+ ...decisionFields(collector.decision()),
1824
+ };
1825
+ }
1826
+ finally {
1827
+ opening.listeners.delete(turn.listener);
1828
+ turn.held.listeners.delete(turn.listener);
1829
+ collector.dispose();
1830
+ // A superseded turn must not arm timers or keepalive on a held session
1831
+ // that now belongs to a newer prompt.
1832
+ const stillMapped = this.turns.get(input.applicationId) === turn;
1833
+ // Nor may a turn that outlived its socket. `recoverTurn` keeps a turn
1834
+ // running on the SABS relay after the close took its session out of the
1835
+ // held map, and `turn.held` still points at that dead object. Arming a
1836
+ // keepalive on it leaves an interval nothing can ever clear - the release
1837
+ // timer no-ops precisely because the session is not current - so it goes
1838
+ // on calling connectLiveEdit for the life of the process.
1839
+ const sessionStillHeld = this.held.get(input.applicationId) === turn.held;
1840
+ if (!sessionStillHeld) {
1841
+ this.clearTimers(turn.held);
1842
+ }
1843
+ else if (stillMapped) {
1844
+ try {
1845
+ await turn.held.client.requestWithTracing(RELEASE_TURN_LOCK_METHOD, this.turnCorrelator(turn.turnId), {}, `Bearer ${input.sessionToken}`);
1846
+ }
1847
+ catch (error) {
1848
+ console.warn("gateway could not release the live-edit turn lock", {
1849
+ applicationId: input.applicationId,
1850
+ message: describeSocketError(error),
1851
+ turnId: turn.turnId,
1852
+ });
1853
+ }
1854
+ if (turn.finished?.generationStatus === "needs_decision") {
1855
+ this.armIdle(turn.held);
1856
+ }
1857
+ else {
1858
+ this.armPostTurnRelease(turn.held, turn);
1859
+ }
1860
+ this.armKeepalive(turn.held);
1861
+ }
1862
+ this.wake(turn);
1863
+ }
1864
+ }
1865
+ pushWaitMs() {
1866
+ return this.options.pushWaitMs ?? DEFAULT_PUSH_WAIT_MS;
1867
+ }
1868
+ /**
1869
+ * Checks that the session took this prompt on as a turn belonging to this
1870
+ * peer, and gives up on it now if it did not.
1871
+ *
1872
+ * A dev server that predates the gateway role accepts the socket, echoes the
1873
+ * role it was offered, and then treats the connection as a library socket: the
1874
+ * prompt is acknowledged and Clark's pushes go to the browser binding
1875
+ * instead. The turn would spend its whole budget looking detached, and a
1876
+ * rollout with one stale sandbox in it would look like Clark hanging. Only a
1877
+ * server that bound the turn can name it back, so the missing handle is the
1878
+ * mismatch, reported where it is still cheap to act on.
1879
+ */
1880
+ assertHeadlessTurnBound(input) {
1881
+ if (input.acknowledgment?.headlessTurn?.turnId === input.turn.turnId) {
1882
+ return;
1883
+ }
1884
+ throw new Error(`Prompt forward failed: the live-edit session acknowledged the prompt but did not bind it to this ${GATEWAY_PROTOCOL} peer, so nothing about the turn would report back. The sandbox is running a dev server from before the ${GATEWAY_PROTOCOL} role. Keep the MCP connection running and retry this app action shortly.`);
1885
+ }
1886
+ /**
1887
+ * The field the session reads to decide which turn a call belongs to. Sent on
1888
+ * the calls that make up a turn's lifetime, so a turn keeps reporting into
1889
+ * this caller across a reattach and no other session can be handed it.
1890
+ */
1891
+ turnCorrelator(turnId) {
1892
+ return turnId ? { headlessTurnId: turnId } : {};
1893
+ }
1894
+ /**
1895
+ * Moves a turn onto a fresh socket while the one it is on is still open,
1896
+ * because Clark has stopped pushing to it. A turn that was running unseen
1897
+ * becomes visible again from where it got to.
1898
+ *
1899
+ * The socket it came in on is still the better of the two whenever the
1900
+ * replacement fails to take the turn over: the sandbox is demonstrably alive,
1901
+ * and it is still pushing this turn somewhere. A caller mid-turn has nothing
1902
+ * to gain from an exception here, so there is no failure to report but the
1903
+ * log line.
1904
+ */
1905
+ async reattach(input) {
1906
+ const previous = input.turn.held;
1907
+ input.report("Reattaching to the Superblocks session");
1908
+ const outcome = await this.takeOverTurn({
1909
+ deadline: Date.now() + (this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
1910
+ previous,
1911
+ turn: input.turn,
1912
+ });
1913
+ if (outcome.kind === "took_over" || outcome.kind === "stale") {
1914
+ return;
1915
+ }
1916
+ console.warn("gateway clark reattach failed", {
1917
+ applicationId: previous.applicationId,
1918
+ message: outcome.kind === "unreachable"
1919
+ ? outcome.message
1920
+ : "the live-edit session would not hand the turn over",
1921
+ });
1922
+ }
1923
+ /**
1924
+ * Whether this turn gets another go at its live edit, booking it if so.
1925
+ *
1926
+ * The allowance is for a live edit that cannot hold a socket, and a turn that
1927
+ * has heard from Clark since its last reconnect is not that: it is getting
1928
+ * somewhere, so its allowance starts over. Without that, a long build behind
1929
+ * a proxy that reaps every few minutes runs out of goes and is failed for the
1930
+ * very reason this path exists to survive.
1931
+ */
1932
+ takeReconnectAllowance(turn, heardAt) {
1933
+ if (heardAt > turn.heardAtLastReconnect) {
1934
+ turn.reconnects = 0;
1935
+ }
1936
+ turn.heardAtLastReconnect = heardAt;
1937
+ const allowed = this.options.reconnectAttempts ?? DEFAULT_RECONNECT_ATTEMPTS;
1938
+ if (turn.reconnects >= allowed) {
1939
+ return false;
1940
+ }
1941
+ turn.reconnects += 1;
1942
+ return true;
1943
+ }
1944
+ /**
1945
+ * Opens a replacement connection and asks the live edit to hand `turn` over
1946
+ * to it.
1947
+ *
1948
+ * Everything that belongs to the session rather than to the socket is carried
1949
+ * across first: the credential the claim itself is made with is one of those
1950
+ * things, and so is the orchestrator a mid-turn tool call needs. The turn's
1951
+ * listener goes on the replacement before the claim, so a push that arrives
1952
+ * while it is in flight is not dropped.
1953
+ *
1954
+ * The turn is claimed before the old socket is let go. The session releases a
1955
+ * turn when the connection holding it closes, so closing first leaves the
1956
+ * claim nothing to take over; claiming first makes the old socket's close a
1957
+ * no-op scoped to a peer that is no longer the owner.
1958
+ *
1959
+ * Nothing has moved when this fails: the listener is back where it was and
1960
+ * the replacement is closed, which leaves the caller free to decide what a
1961
+ * socket it could not replace means.
1962
+ */
1963
+ async takeOverTurn(input) {
1964
+ // One attempt at a time per turn. Both paths that reach for a replacement
1965
+ // answer the same stall - the pushes stop, then the close lands - and the
1966
+ // second cannot see the first from `turn.held`, which the first has not
1967
+ // reassigned yet. Two attempts would each claim the turn on their own
1968
+ // socket and orphan the loser, so the second waits on the first and acts
1969
+ // on what it achieved.
1970
+ const running = input.turn.takingOver;
1971
+ if (running) {
1972
+ return await running;
1973
+ }
1974
+ const attempt = this.attemptTakeOver(input);
1975
+ input.turn.takingOver = attempt;
1976
+ try {
1977
+ return await attempt;
1978
+ }
1979
+ finally {
1980
+ if (input.turn.takingOver === attempt) {
1981
+ input.turn.takingOver = undefined;
1982
+ }
1983
+ }
1984
+ }
1985
+ async attemptTakeOver(input) {
1986
+ const { previous, turn } = input;
1987
+ let fresh;
1988
+ try {
1989
+ fresh = await this.reconnect(previous, input.deadline);
1990
+ }
1991
+ catch (error) {
1992
+ return {
1993
+ kind: "unreachable",
1994
+ message: error instanceof Error ? error.message : String(error),
1995
+ };
1996
+ }
1997
+ this.carryOver(fresh, previous);
1998
+ fresh.pendingTurn = turn;
1999
+ fresh.listeners.add(turn.listener);
2000
+ previous.listeners.delete(turn.listener);
2001
+ fresh.heartbeat.start();
2002
+ const claim = fresh.sessionToken === undefined
2003
+ ? "refused"
2004
+ : await this.proveIdentity({
2005
+ held: fresh,
2006
+ sessionToken: fresh.sessionToken,
2007
+ turnId: turn.turnId,
2008
+ });
2009
+ // Opening and claiming take seconds, and the turn can settle inside them:
2010
+ // a relayed terminal event, its own budget running out, a newer prompt
2011
+ // taking the app. runTurn arms the post-turn timers once, against the
2012
+ // session the turn held when it ended, so a replacement committed after
2013
+ // that is a socket nothing will ever close. Nothing has been committed at
2014
+ // this point but the listener, so there is nothing to undo beyond letting
2015
+ // the replacement go.
2016
+ if (turn.finished || turn.held !== previous) {
2017
+ delete fresh.pendingTurn;
2018
+ fresh.listeners.delete(turn.listener);
2019
+ if (fresh.listeners.size === 0 &&
2020
+ this.turns.get(fresh.applicationId)?.held !== fresh) {
2021
+ this.drop(fresh);
2022
+ }
2023
+ return { kind: "stale" };
2024
+ }
2025
+ if (claim === "unreachable") {
2026
+ delete fresh.pendingTurn;
2027
+ fresh.listeners.delete(turn.listener);
2028
+ this.drop(fresh);
2029
+ return {
2030
+ kind: "unreachable",
2031
+ message: "the live-edit session stopped answering on a fresh socket",
2032
+ };
2033
+ }
2034
+ if (claim === "refused") {
2035
+ delete fresh.pendingTurn;
2036
+ fresh.listeners.delete(turn.listener);
2037
+ // Go back to the socket the turn was on, but only while it is still
2038
+ // there: a live edit that would not hand the turn over is still pushing
2039
+ // it to the connection that started it. A replacement for a socket that
2040
+ // already closed has nothing to go back to, and re-registering a dead
2041
+ // session would leave the next prompt to discover it.
2042
+ if (isSocketOpen(previous.ws)) {
2043
+ previous.listeners.add(turn.listener);
2044
+ this.setHeld(previous);
2045
+ }
2046
+ this.drop(fresh);
2047
+ return { kind: "refused" };
2048
+ }
2049
+ delete fresh.pendingTurn;
2050
+ delete fresh.provisional;
2051
+ turn.held = fresh;
2052
+ this.setHeld(fresh);
2053
+ fresh.heartbeat.start();
2054
+ this.drop(previous);
2055
+ return { kind: "took_over" };
2056
+ }
2057
+ /**
2058
+ * Carries onto a replacement socket everything that belongs to the session
2059
+ * rather than to the connection: who the turn runs as, what it is allowed to
2060
+ * do, and which orchestrator its tool calls go to. A replacement missing any
2061
+ * of it is connected but cannot act for the caller.
2062
+ */
2063
+ carryOver(fresh, previous) {
2064
+ fresh.apiKey = previous.apiKey;
2065
+ fresh.branchName = previous.branchName;
2066
+ fresh.orchestrator = previous.orchestrator;
2067
+ fresh.orchestratorUrl = previous.orchestratorUrl;
2068
+ fresh.organizationId = previous.organizationId;
2069
+ fresh.profileKey = previous.profileKey;
2070
+ fresh.profiles = previous.profiles;
2071
+ fresh.sessionToken = previous.sessionToken;
2072
+ fresh.syncStatus = previous.syncStatus;
2073
+ }
2074
+ /**
2075
+ * Makes one authenticated read on a freshly opened socket.
2076
+ *
2077
+ * The dev server trusts a peer's identity per connection and does not carry
2078
+ * it over from the peer this one replaced, so it withholds the pushes it
2079
+ * scopes to a caller — Clark's step-by-step task status — until the new
2080
+ * connection has proven who it is. Reading the chat is the cheapest way to
2081
+ * prove it, and it costs a turn nothing when the read fails.
2082
+ *
2083
+ * Naming `turnId` is what makes this call a reattach rather than just an
2084
+ * authenticated read: the session only moves a turn onto a connection that
2085
+ * can say which turn it is taking over.
2086
+ *
2087
+ * Answers whether it worked, because a caller mid-reattach has to decide
2088
+ * between two sockets on it.
2089
+ */
2090
+ async proveIdentity(input) {
2091
+ try {
2092
+ await editorSocket(input.held, input.sessionToken).aiChatGetMessages({
2093
+ force: false,
2094
+ ...this.turnCorrelator(input.turnId),
2095
+ });
2096
+ return "claimed";
2097
+ }
2098
+ catch (error) {
2099
+ const message = describeSocketError(error);
2100
+ console.warn("gateway clark reattach identity call failed", {
2101
+ applicationId: input.held.applicationId,
2102
+ message,
2103
+ });
2104
+ // A call that went unanswered says the socket opened onto something that
2105
+ // is not servicing requests, which is the same fact a lost liveness beat
2106
+ // reports and nothing like a session declining to hand a turn over.
2107
+ return isTimeout({ message }) ? "unreachable" : "refused";
2108
+ }
2109
+ }
2110
+ async reconnect(previous, deadline) {
2111
+ return await this.connect({
2112
+ applicationId: previous.applicationId,
2113
+ branchName: previous.branchName,
2114
+ keepalive: previous.keepalive,
2115
+ // Both halves, so a reattach keeps listening on the session it was
2116
+ // already on: the socket is being replaced, not the live edit.
2117
+ liveEdit: previous.liveEdit,
2118
+ // The address in hand is one this turn has already been served on, so
2119
+ // a session that moved out from under it denies every attempt made
2120
+ // here. Re-resolving on the way in is not enough for a turn that
2121
+ // outlives its socket.
2122
+ ...(previous.resolveLiveEdit
2123
+ ? { resolveLiveEdit: previous.resolveLiveEdit }
2124
+ : {}),
2125
+ userId: previous.userId,
2126
+ }, deadline, false);
2127
+ }
2128
+ setHeld(held) {
2129
+ this.held.set(held.applicationId, held);
2130
+ this.publishEditorStructure(held);
2131
+ }
2132
+ recordEditorStructure(held, hasDefaultStructure) {
2133
+ held.editorHasDefaultStructure = hasDefaultStructure;
2134
+ this.publishEditorStructure(held);
2135
+ }
2136
+ publishEditorStructure(held) {
2137
+ if (held.dropping ||
2138
+ this.held.get(held.applicationId) !== held ||
2139
+ !held.editorHasDefaultStructure) {
2140
+ return;
2141
+ }
2142
+ this.editorStructureByScope.set(editorStructureScopeKey(held.applicationId, held.branchName), held.editorHasDefaultStructure);
2143
+ }
2144
+ currentViewStatus(applicationId, branch, fallback) {
2145
+ const hasDefaultStructure = this.editorStructureByScope.get(editorStructureScopeKey(applicationId, branch));
2146
+ if (!hasDefaultStructure) {
2147
+ return fallback;
2148
+ }
2149
+ return hasDefaultStructure === "no" ? "ready" : "unknown";
2150
+ }
2151
+ /** Lines this caller has not been handed yet. */
2152
+ drain(turn) {
2153
+ const pending = turn.activity.slice(turn.delivered);
2154
+ turn.delivered = turn.activity.length;
2155
+ return pending;
2156
+ }
2157
+ wake(turn) {
2158
+ for (const waiter of [...turn.waiters]) {
2159
+ waiter();
2160
+ }
2161
+ }
2162
+ /** Adds a line to what the next poll on this turn is handed. */
2163
+ note(turn, message) {
2164
+ turn.activity.push(message);
2165
+ this.wake(turn);
2166
+ }
2167
+ /**
2168
+ * Records the outcome of a turn from outside runTurn, and ends the collector
2169
+ * waits so runTurn cannot overwrite it with a later timeout of its own. The
2170
+ * first outcome written wins: it is the one callers have already been shown.
2171
+ */
2172
+ settleTurn(turn, finished) {
2173
+ turn.finished ??= finished;
2174
+ turn.abort.abort();
2175
+ this.wake(turn);
2176
+ }
2177
+ /**
2178
+ * Mirrors the browser editor's auto-accept: wait for the dev server to flush
2179
+ * Clark's files, then commit the draft. Failing to checkpoint costs the caller
2180
+ * a committed app, not the generation itself, so this never throws.
2181
+ */
2182
+ async checkpoint(input) {
2183
+ const inSync = await input.collector.waitUntilInSync({
2184
+ timeoutMs: input.readyWaitMs,
2185
+ });
2186
+ console.info("gateway clark checkpoint sync wait", {
2187
+ applicationId: input.held.applicationId,
2188
+ inSync,
2189
+ });
2190
+ if (!inSync) {
2191
+ input.onProgress?.("Clark's files are still saving; the app will finish in the editor");
2192
+ recordCheckpointOutcome("sync_timeout");
2193
+ return {
2194
+ failure: {
2195
+ category: "timeout",
2196
+ message: "Clark's files did not finish saving before the checkpoint deadline",
2197
+ },
2198
+ status: "failed",
2199
+ };
2200
+ }
2201
+ input.onProgress?.("Saving a checkpoint");
2202
+ try {
2203
+ const result = await editorSocket(input.held, input.sessionToken).aiAcceptDraft({
2204
+ screenshotDataUrl: null,
2205
+ silently: true,
2206
+ ...this.turnCorrelator(input.turnId),
2207
+ });
2208
+ input.collector.markCheckpointed();
2209
+ const commitId = result?.commitId?.trim() ? result.commitId : undefined;
2210
+ const warning = commitId
2211
+ ? normalizeCheckpointWarning(result?.warning)
2212
+ : undefined;
2213
+ // The accept writes draftCommitted on the server. The warm sandbox still
2214
+ // holds the chat it loaded earlier, so a browser that opens this same
2215
+ // session and loads chat without force never sees the checkpoint card.
2216
+ // Force-reloading now puts the server message into the sandbox store.
2217
+ if (commitId) {
2218
+ try {
2219
+ await editorSocket(input.held, input.sessionToken).aiChatGetMessages({
2220
+ force: true,
2221
+ ...this.turnCorrelator(input.turnId),
2222
+ });
2223
+ }
2224
+ catch (error) {
2225
+ console.warn("gateway clark chat reload after checkpoint failed", {
2226
+ applicationId: input.held.applicationId,
2227
+ message: error instanceof Error ? error.message : String(error),
2228
+ });
2229
+ }
2230
+ }
2231
+ console.info("gateway clark checkpoint saved", {
2232
+ applicationId: input.held.applicationId,
2233
+ commitId: commitId ?? "none",
2234
+ });
2235
+ input.onProgress?.(warning?.message ??
2236
+ (commitId ? "Checkpoint saved" : "App already up to date"));
2237
+ recordCheckpointOutcome(warning ? "cleanup_warning" : commitId ? "saved" : "unchanged");
2238
+ return {
2239
+ ...(commitId ? { commitId } : {}),
2240
+ status: "saved",
2241
+ ...(warning ? { warning } : {}),
2242
+ };
2243
+ }
2244
+ catch (error) {
2245
+ const failure = checkpointFailure(error);
2246
+ recordCheckpointOutcome(`${failure.category}_failure`);
2247
+ console.warn("gateway clark checkpoint failed", {
2248
+ applicationId: input.held.applicationId,
2249
+ message: `${failure.message}${failure.code === undefined ? "" : ` (code ${failure.code})`}`,
2250
+ });
2251
+ input.onProgress?.("Could not save a checkpoint for Superblocks' changes");
2252
+ return { failure, status: "failed" };
2253
+ }
2254
+ }
2255
+ async ensureHeld(input, deadline, expectedTurn) {
2256
+ this.assertReadOnlyHoldCurrent(input.applicationId, expectedTurn);
2257
+ const existing = this.held.get(input.applicationId);
2258
+ // A connect that followed a moved session left this record pointing
2259
+ // somewhere the prompt's own resolve never saw. Overlapping forwards are
2260
+ // ordinary - Clark retries - so the prompt behind the move still names the
2261
+ // host the session left; reading that as a different session would drop
2262
+ // the socket the move just won and dial the host that stopped answering.
2263
+ const sameUrl = existing?.liveEdit.url === input.liveEdit.url;
2264
+ if (existing &&
2265
+ (sameUrl || existing.movedFrom === input.liveEdit.url) &&
2266
+ isSocketOpen(existing.ws)) {
2267
+ // The held map is keyed by applicationId alone. Reusing a socket opened
2268
+ // for a different user would attribute Clark work to the wrong principal
2269
+ // and keep their session JWT on the wire.
2270
+ if (existing.userId !== input.userId) {
2271
+ this.drop(existing);
2272
+ }
2273
+ else {
2274
+ existing.branchName = input.branchName;
2275
+ // A new prompt is a new turn, and the beat-lost clock measures one
2276
+ // turn's silence. Carrying a running clock across would judge this
2277
+ // prompt on how quiet the socket was before it was sent.
2278
+ existing.beatLoss = undefined;
2279
+ // The caller resolved the live edit for this prompt, so its id is at
2280
+ // least as fresh as whatever an earlier prompt left here - but only
2281
+ // when it named the host this socket is on. A prompt reusing through
2282
+ // `movedFrom` resolved before the move, so its id is the one the move
2283
+ // replaced, and taking it would undo what the move established. An
2284
+ // absent id is not news either way and must not clear what is here.
2285
+ if (sameUrl && input.liveEdit.id) {
2286
+ existing.liveEdit = {
2287
+ id: input.liveEdit.id,
2288
+ url: existing.liveEdit.url,
2289
+ };
2290
+ }
2291
+ if (input.keepalive) {
2292
+ existing.keepalive = input.keepalive;
2293
+ }
2294
+ if (input.resolveLiveEdit) {
2295
+ existing.resolveLiveEdit = input.resolveLiveEdit;
2296
+ }
2297
+ return existing;
2298
+ }
2299
+ }
2300
+ const pending = this.connecting.get(input.applicationId);
2301
+ if (pending) {
2302
+ // Settle behind the open already in flight, then re-decide: it may have
2303
+ // produced the socket this caller wants, or failed and left nothing.
2304
+ await pending.catch(() => undefined);
2305
+ return await this.ensureHeld(input, deadline, expectedTurn);
2306
+ }
2307
+ if (existing) {
2308
+ this.drop(existing);
2309
+ }
2310
+ const connect = this.connect(input, deadline, true, expectedTurn);
2311
+ this.connecting.set(input.applicationId, connect);
2312
+ try {
2313
+ return await connect;
2314
+ }
2315
+ finally {
2316
+ this.connecting.delete(input.applicationId);
2317
+ }
2318
+ }
2319
+ async connect(input, deadline, publish = true, expectedTurn) {
2320
+ // Unique per connection. The dev server ignores the disconnect of a peerId
2321
+ // that is no longer the bound one, so a reattach that closes its
2322
+ // predecessor afterwards keeps the binding it just took; reusing one id
2323
+ // would have that close clear it.
2324
+ const peerId = `gateway-held-${input.applicationId}-${randomUUID()}`;
2325
+ const url = buildSessionUrl({
2326
+ applicationId: input.applicationId,
2327
+ liveEditUrl: input.liveEdit.url,
2328
+ peerId,
2329
+ userId: input.userId,
2330
+ });
2331
+ // Where the session turned out to be, which is what the held record has to
2332
+ // remember: the value the prompt arrived with describes where it was looked
2333
+ // for, not where the socket ended up.
2334
+ let liveEdit = input.liveEdit;
2335
+ const resolveLiveEdit = input.resolveLiveEdit;
2336
+ const close = {};
2337
+ const ws = await open(url, this.options.connectAttemptTimeoutMs ??
2338
+ DEFAULT_CONNECT_ATTEMPT_TIMEOUT_MS, deadline, (error) => {
2339
+ close.error = error.message;
2340
+ }, resolveLiveEdit &&
2341
+ (async () => {
2342
+ try {
2343
+ const moved = await resolveLiveEdit();
2344
+ const next = buildSessionUrl({
2345
+ applicationId: input.applicationId,
2346
+ liveEditUrl: moved.liveEditUrl,
2347
+ peerId,
2348
+ userId: input.userId,
2349
+ });
2350
+ // Recorded whether or not the host changed. A resolve that lands
2351
+ // back on the same host is still the freshest word on which live
2352
+ // edit that host is serving. Recorded only once the URL is built,
2353
+ // so a session that cannot be reached does not overwrite the one
2354
+ // the socket may yet connect to. Taken as one value: a resolve that
2355
+ // names a host but not the session on it leaves this with no id
2356
+ // rather than the one from before the move, so the turn falls back
2357
+ // to the socket instead of listening on a live edit that moved.
2358
+ liveEdit = liveEditOf(moved);
2359
+ console.warn("gateway re-resolved a live-edit host", {
2360
+ applicationId: input.applicationId,
2361
+ from: new URL(input.liveEdit.url).host,
2362
+ liveEditId: liveEdit.id,
2363
+ peerId,
2364
+ to: next.host,
2365
+ });
2366
+ return next;
2367
+ }
2368
+ catch (error) {
2369
+ // Reported rather than raised: the connect loop's fallback is the
2370
+ // host already in hand, and losing that to a failed lookup would
2371
+ // turn a recoverable stale binding into no attempt at all.
2372
+ console.warn("gateway could not re-resolve a live-edit host", {
2373
+ applicationId: input.applicationId,
2374
+ message: error instanceof Error ? error.message : String(error),
2375
+ peerId,
2376
+ });
2377
+ return undefined;
2378
+ }
2379
+ }));
2380
+ // Registered before ISocket takes the socket so it runs ahead of ISocket's
2381
+ // own close handler, which is handed neither code nor reason.
2382
+ ws.on("close", (code, reason) => {
2383
+ close.code = code;
2384
+ const text = reason?.toString().trim();
2385
+ if (text) {
2386
+ close.reason = text;
2387
+ }
2388
+ });
2389
+ // Same cast the smoke client uses: ISocket's browser WebSocket type and
2390
+ // the `ws` package's Node type are structurally compatible at runtime but
2391
+ // not assignable under TypeScript's DOM lib.
2392
+ const heldRef = {
2393
+ current: undefined,
2394
+ };
2395
+ const socket = new ISocket(ws, createEditorClientMethods(() => {
2396
+ const held = heldRef.current;
2397
+ if (!held) {
2398
+ return undefined;
2399
+ }
2400
+ return (event) => {
2401
+ if (event.kind === "sync_status" && event.status) {
2402
+ held.syncStatus = event.status;
2403
+ }
2404
+ if (event.kind === "editor_structure") {
2405
+ this.recordEditorStructure(held, event.hasDefaultStructure);
2406
+ }
2407
+ if (isBrowserObserverEvent(event)) {
2408
+ this.observeEditorEvent(held, event);
2409
+ return;
2410
+ }
2411
+ if (held.listeners.size > 0) {
2412
+ for (const listener of [...held.listeners]) {
2413
+ listener(event);
2414
+ }
2415
+ return;
2416
+ }
2417
+ };
2418
+ }, (method) => {
2419
+ this.inboundMethods.add(method);
2420
+ }, {
2421
+ getContext: () => this.editorContextFor(heldRef.current),
2422
+ onToolRun: (run) => {
2423
+ const current = heldRef.current;
2424
+ if (!current) {
2425
+ return;
2426
+ }
2427
+ const turn = current.pendingTurn ?? this.turns.get(current.applicationId);
2428
+ if (!turn ||
2429
+ turn.finished ||
2430
+ (turn.held !== current && current.pendingTurn !== turn)) {
2431
+ return;
2432
+ }
2433
+ turn.recentRuns = appendRecentToolRun(turn.recentRuns, buildRecentToolRun(run));
2434
+ const mark = run.success ? "passed" : "failed";
2435
+ const line = `API test ${mark}: ${run.name}`;
2436
+ turn.activity.push(line);
2437
+ this.wake(turn);
2438
+ },
2439
+ respondToToolPermission: async (payload) => {
2440
+ const current = heldRef.current;
2441
+ if (!current) {
2442
+ return;
2443
+ }
2444
+ const turn = current.pendingTurn ?? this.turns.get(current.applicationId);
2445
+ if (!turn ||
2446
+ turn.finished ||
2447
+ (turn.held !== current && current.pendingTurn !== turn)) {
2448
+ return;
2449
+ }
2450
+ try {
2451
+ await editorSocket(current, current.sessionToken).aiRespondToToolPermission({
2452
+ approved: payload.approved,
2453
+ ...this.turnCorrelator(turn.turnId),
2454
+ messageId: payload.messageId,
2455
+ requestId: payload.requestId ?? payload.messageId,
2456
+ });
2457
+ }
2458
+ catch (error) {
2459
+ console.warn("gateway auto-approve tool permission failed", {
2460
+ applicationId: current.applicationId,
2461
+ message: error instanceof Error ? error.message : String(error),
2462
+ });
2463
+ }
2464
+ },
2465
+ }), [], {
2466
+ onClose: () => {
2467
+ const current = this.held.get(input.applicationId);
2468
+ if (current?.ws === ws) {
2469
+ console.error("gateway held session closed", {
2470
+ applicationId: input.applicationId,
2471
+ requested: Boolean(current.dropping),
2472
+ });
2473
+ this.clearTimers(current);
2474
+ current.heartbeat.stop();
2475
+ this.held.delete(input.applicationId);
2476
+ }
2477
+ const held = heldRef.current;
2478
+ if (held?.provisional === true || (!held && !publish)) {
2479
+ return;
2480
+ }
2481
+ // Its own closes - a reattach swapping sockets, a release - leave the
2482
+ // turn either running elsewhere or already settled.
2483
+ if (held?.dropping) {
2484
+ return;
2485
+ }
2486
+ const observed = held
2487
+ ? this.observedTurns.get(held.applicationId)
2488
+ : undefined;
2489
+ if (held && observed?.held === held && !observed.finished) {
2490
+ const applicationEnded = isApplicationClose(close);
2491
+ this.settleObservedTurn(observed, applicationEnded ? "live_edit_terminated" : "detached", applicationEnded
2492
+ ? LIVE_EDIT_TERMINATED_REPLY + describeClose(close)
2493
+ : observed.collector.reply());
2494
+ return;
2495
+ }
2496
+ // An application code is the live edit stating its own end, so there
2497
+ // is nothing left to get back onto. Any other close is the
2498
+ // transport's, and a turn is not over because the socket carrying it
2499
+ // is: the gateway goes and looks before saying so.
2500
+ if (!held || isApplicationClose(close)) {
2501
+ this.abandonTurnOnDisconnect(input.applicationId, close, held?.heartbeat.account);
2502
+ return;
2503
+ }
2504
+ void this.recoverTurn({ close, previous: held });
2505
+ },
2506
+ timeouts: {
2507
+ connectionTimeoutInSeconds: undefined,
2508
+ // Held across prompts, so budget each RPC the full timeout rather than
2509
+ // whatever remained of the connect deadline from the first open.
2510
+ noResponseTimeoutInSeconds: (this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000,
2511
+ },
2512
+ });
2513
+ const client = createISocketClient(socket);
2514
+ const heartbeat = this.buildHeartbeat(heldRef);
2515
+ const held = {
2516
+ applicationId: input.applicationId,
2517
+ branchName: input.branchName,
2518
+ client,
2519
+ heartbeat,
2520
+ idleTimer: undefined,
2521
+ keepalive: input.keepalive,
2522
+ keepaliveTimer: undefined,
2523
+ listeners: new Set(),
2524
+ liveEdit,
2525
+ ...(input.resolveLiveEdit
2526
+ ? { resolveLiveEdit: input.resolveLiveEdit }
2527
+ : {}),
2528
+ ...(liveEdit.url === input.liveEdit.url
2529
+ ? {}
2530
+ : { movedFrom: input.liveEdit.url }),
2531
+ peerId,
2532
+ ...(!publish ? { provisional: true } : {}),
2533
+ socket,
2534
+ userId: input.userId,
2535
+ ws,
2536
+ };
2537
+ heldRef.current = held;
2538
+ if (publish) {
2539
+ try {
2540
+ this.assertReadOnlyHoldCurrent(input.applicationId, expectedTurn);
2541
+ }
2542
+ catch (error) {
2543
+ this.drop(held);
2544
+ throw error;
2545
+ }
2546
+ this.setHeld(held);
2547
+ }
2548
+ // Inbound frames are what separates a dead connection from a request
2549
+ // channel that stopped answering on a live one.
2550
+ ws.on("message", () => {
2551
+ heartbeat.recordReceive();
2552
+ });
2553
+ // Beats for as long as the socket is held, including the grace period after
2554
+ // a turn settles: the checkpoint lands in that window and the sandbox has
2555
+ // to still be there for it.
2556
+ if (publish) {
2557
+ heartbeat.start();
2558
+ }
2559
+ return held;
2560
+ }
2561
+ assertReadOnlyHoldCurrent(applicationId, expectedTurn) {
2562
+ const current = this.turns.get(applicationId);
2563
+ const ownsPublishedSession = current && this.held.get(applicationId) === current.held;
2564
+ if (current !== expectedTurn ||
2565
+ (current && (!current.finished || ownsPublishedSession))) {
2566
+ throw new Error("Live-edit hold skipped because the application has an active turn or completed a turn while the session opened");
2567
+ }
2568
+ }
2569
+ /**
2570
+ * The liveness beat for one held socket.
2571
+ *
2572
+ * Everything about pacing, deadlines and when to believe a silence lives in
2573
+ * {@link SocketHeartbeat}; what belongs here is only what is specific to this
2574
+ * peer - which call to make, what counts as unanswered on this client, and
2575
+ * who to tell.
2576
+ */
2577
+ buildHeartbeat(heldRef) {
2578
+ return new SocketHeartbeat({
2579
+ beat: async () => {
2580
+ const held = heldRef.current;
2581
+ if (!held?.sessionToken) {
2582
+ // Unreachable behind `shouldBeat`. Thrown rather than returned so a
2583
+ // beat that never went out can never be recorded as one that landed.
2584
+ throw new Error("live-edit session went away before the beat was sent");
2585
+ }
2586
+ await editorSocket(held, held.sessionToken).ping(this.turnCorrelator((held.pendingTurn ?? this.turns.get(held.applicationId))?.turnId));
2587
+ },
2588
+ describeError: describeSocketError,
2589
+ intervalMs: this.options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
2590
+ // This client rejects with a plain `{ code, message }` object rather than
2591
+ // an Error, so a timeout is only recognizable from the rendered message.
2592
+ isUnanswered: (error) => isTimeout({ message: describeSocketError(error) }),
2593
+ maxConsecutiveMisses: this.options.heartbeatMaxMisses,
2594
+ maxConsecutiveUnanswered: this.options.heartbeatMaxUnanswered,
2595
+ onLost: (account) => {
2596
+ const held = heldRef.current;
2597
+ console.warn("gateway liveness beat lost", {
2598
+ applicationId: held?.applicationId,
2599
+ failures: account.failures,
2600
+ lapses: account.lapses,
2601
+ message: account.lastError,
2602
+ });
2603
+ },
2604
+ onOutcome: (outcome, durationMs, account) => {
2605
+ const held = heldRef.current;
2606
+ if (outcome !== "success") {
2607
+ console.warn(`gateway liveness beat ${outcome}`, {
2608
+ applicationId: held?.applicationId,
2609
+ durationMs,
2610
+ failures: account.failures,
2611
+ lapses: account.lapses,
2612
+ message: account.lastError,
2613
+ });
2614
+ }
2615
+ if (!held) {
2616
+ return;
2617
+ }
2618
+ if (held.provisional) {
2619
+ return;
2620
+ }
2621
+ // The clock measures silence, so anything that is not silence ends it.
2622
+ // A success and a refusal both prove the peer answered this beat - and
2623
+ // a refusal must never cost a turn, since it is an application-level
2624
+ // problem on a reachable connection. A lapse is the heartbeat's own
2625
+ // verdict that inbound traffic explained the quiet, which un-latches
2626
+ // its alarm too. Only a genuinely unanswered beat keeps the clock.
2627
+ if (outcome !== "grace" && outcome !== "miss") {
2628
+ held.beatLoss = undefined;
2629
+ return;
2630
+ }
2631
+ // Started here rather than from `onLost`, which fires only on the edge
2632
+ // into the lost state. A refusal stops this clock without un-latching
2633
+ // that state, so the edge has already been spent and would never come
2634
+ // again - and silence returning after a refusal could then never end
2635
+ // the turn. `miss` is that same edge as the heartbeat draws it: `grace`
2636
+ // is still inside the missed-beat allowance, so it keeps a clock that
2637
+ // is already running without starting one.
2638
+ if (outcome === "miss") {
2639
+ held.beatLoss ??= { at: Date.now() };
2640
+ }
2641
+ const beatLoss = held.beatLoss;
2642
+ if (!beatLoss) {
2643
+ return;
2644
+ }
2645
+ const graceMs = this.options.beatLostGraceMs ?? DEFAULT_BEAT_LOST_GRACE_MS;
2646
+ if (Date.now() - beatLoss.at >= graceMs && !beatLoss.healthCheck) {
2647
+ beatLoss.healthCheck = this.confirmBeatLostSession(held, account, beatLoss);
2648
+ }
2649
+ },
2650
+ // The sandbox grants liveness on proof of authorization, so a beat sent
2651
+ // before this session has a credential would be refused; skipping it
2652
+ // costs nothing, because a session with no credential has no turn to
2653
+ // protect yet either.
2654
+ shouldBeat: () => {
2655
+ const held = heldRef.current;
2656
+ return (held !== undefined &&
2657
+ Boolean(held.sessionToken) &&
2658
+ isSocketOpen(held.ws));
2659
+ },
2660
+ timeoutMs: this.options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS,
2661
+ });
2662
+ }
2663
+ async confirmBeatLostSession(held, heartbeat, beatLoss) {
2664
+ const applicationId = held.applicationId;
2665
+ const turn = this.turns.get(applicationId);
2666
+ const activeTurn = turn?.held === held &&
2667
+ !turn.finished &&
2668
+ !turn.collector.status() &&
2669
+ !turn.collector.isSettling()
2670
+ ? turn
2671
+ : undefined;
2672
+ const observed = this.observedTurns.get(applicationId);
2673
+ const activeObserved = observed?.held === held &&
2674
+ !observed.finished &&
2675
+ !observed.collector.status() &&
2676
+ !observed.collector.isSettling()
2677
+ ? observed
2678
+ : undefined;
2679
+ const activeTurnStateCount = activeTurn?.collector.stateSequence().length;
2680
+ const activeObservedStateCount = activeObserved?.collector.stateSequence().length;
2681
+ if (!activeTurn && !activeObserved) {
2682
+ if (held.beatLoss === beatLoss) {
2683
+ held.beatLoss = undefined;
2684
+ }
2685
+ return;
2686
+ }
2687
+ const healthy = await liveEditIsHealthy({
2688
+ applicationId,
2689
+ liveEditUrl: held.liveEdit.url,
2690
+ sessionToken: held.sessionToken,
2691
+ });
2692
+ const turnIsCurrent = activeTurn !== undefined &&
2693
+ this.turns.get(applicationId) === activeTurn &&
2694
+ !activeTurn.finished &&
2695
+ !activeTurn.collector.status() &&
2696
+ !activeTurn.collector.isSettling() &&
2697
+ activeTurn.collector.stateSequence().length === activeTurnStateCount;
2698
+ const observedIsCurrent = activeObserved !== undefined &&
2699
+ this.observedTurns.get(applicationId) === activeObserved &&
2700
+ !activeObserved.finished &&
2701
+ !activeObserved.collector.status() &&
2702
+ !activeObserved.collector.isSettling() &&
2703
+ activeObserved.collector.stateSequence().length ===
2704
+ activeObservedStateCount;
2705
+ if (held.dropping ||
2706
+ this.held.get(applicationId) !== held ||
2707
+ held.beatLoss !== beatLoss) {
2708
+ return;
2709
+ }
2710
+ held.beatLoss = undefined;
2711
+ if (healthy || (!turnIsCurrent && !observedIsCurrent)) {
2712
+ return;
2713
+ }
2714
+ this.abandonTurnOnBeatLost(held, heartbeat, turnIsCurrent ? activeTurn : undefined, observedIsCurrent ? activeObserved : undefined);
2715
+ }
2716
+ armIdle(held) {
2717
+ if (held.idleTimer) {
2718
+ clearTimeout(held.idleTimer);
2719
+ }
2720
+ const idleMs = this.options.idleMs ?? DEFAULT_IDLE_MS;
2721
+ held.idleTimer = setTimeout(() => {
2722
+ this.release(held.applicationId);
2723
+ }, idleMs);
2724
+ held.idleTimer.unref?.();
2725
+ }
2726
+ /**
2727
+ * Ends a turn whose live edit is gone.
2728
+ *
2729
+ * The dev server exits on its own when its lock is lost, and it says nothing
2730
+ * on the way out. A turn left waiting on that socket burns its entire budget
2731
+ * — ten minutes — before the caller is told anything.
2732
+ *
2733
+ * Reached only once the live edit being gone is established, which a close on
2734
+ * its own never does: either it named its own end in the application range,
2735
+ * or {@link recoverTurn} could not get back to it for the whole reconnect
2736
+ * grace. `live_edit_terminated` is a claim about the sandbox, so it is worth
2737
+ * only as much as the evidence behind it. The close code and heartbeat
2738
+ * account go in the reply, since they are the only account of the cause that
2739
+ * reaches the caller.
2740
+ */
2741
+ abandonTurnOnDisconnect(applicationId, close = {}, heartbeat) {
2742
+ const turn = this.turns.get(applicationId);
2743
+ if (!turn || turn.finished) {
2744
+ return;
2745
+ }
2746
+ const beats = heartbeat ? describeHeartbeat(heartbeat, Date.now()) : "";
2747
+ console.warn("gateway clark turn abandoned", {
2748
+ applicationId,
2749
+ beatFailures: heartbeat?.failures,
2750
+ beatLapses: heartbeat?.lapses,
2751
+ // A refusal never counts as a failure, so without these two a death that
2752
+ // the reply explains in full - every answer an error, or a silence the
2753
+ // socket's own traffic excused - logs as `beatFailures: 0`, which reads
2754
+ // as a beat that had nothing to report.
2755
+ beatRefusals: heartbeat?.refusals,
2756
+ beatsSent: heartbeat?.sent,
2757
+ beatsUnanswered: heartbeat?.unanswered,
2758
+ closeCode: close.code,
2759
+ closeError: close.error,
2760
+ closeReason: close.reason,
2761
+ lastBeatError: heartbeat?.lastError,
2762
+ lastBeatSuccessAt: heartbeat?.lastSuccessAt,
2763
+ pendingBeatSentAt: heartbeat?.pendingSince,
2764
+ reason: "live-edit session disconnected",
2765
+ });
2766
+ this.settleAsLiveEditTerminated(turn, describeClose(close) + beats);
2767
+ }
2768
+ /**
2769
+ * Ends a turn SABS has just said has no live edit left to run on.
2770
+ *
2771
+ * A socket close is the other trigger for this same outcome, and it is the
2772
+ * slower one: the dev server exits silently when it loses its lock, so the
2773
+ * close can be minutes late or never arrive at all. This event is the control
2774
+ * plane stating the session is gone, which is the same fact without waiting
2775
+ * for a connection to notice it.
2776
+ *
2777
+ * Only for a turn that has not settled itself, though. Every healthy build
2778
+ * ends with its session torn down too, and reading that as a dead live edit
2779
+ * would report each finished app as a failure. A collector that already
2780
+ * settled on a terminal state keeps it; the teardown is the outcome only for
2781
+ * a turn still waiting for one. The disconnect path needs no such guard - an
2782
+ * unsolicited close mid-turn is abnormal in its own right, where a teardown
2783
+ * is what the end of every turn looks like.
2784
+ *
2785
+ * Settled, not merely reached: a terminal state is provisional until the
2786
+ * quiet window closes, because a build that resumes after a false
2787
+ * completed/idle clears it. A teardown inside that window therefore still
2788
+ * ends the turn - which is the honest answer, since the checkpoint that
2789
+ * would follow runs over the socket to the dev server that just died and
2790
+ * could not have landed either way.
2791
+ */
2792
+ abandonTurnOnSessionTerminated(applicationId, turn) {
2793
+ if (turn.finished || turn.collector.status()) {
2794
+ return;
2795
+ }
2796
+ console.warn("gateway clark turn abandoned", {
2797
+ applicationId,
2798
+ reason: "SABS terminated the live-edit session",
2799
+ });
2800
+ this.settleAsLiveEditTerminated(turn, "");
2801
+ }
2802
+ /**
2803
+ * Ends a turn whose socket has stopped answering.
2804
+ *
2805
+ * Three gates answer different questions. `SocketHeartbeat`
2806
+ * declares the beat lost after consecutive genuine misses, having already
2807
+ * excused a run of unanswered beats and every silence that inbound traffic
2808
+ * explained - the right threshold for a diagnostic, and too eager for this: a
2809
+ * sandbox that misses two beats and answers the next was recovering, and its
2810
+ * turn has to survive that. So the loss also has to outlast
2811
+ * {@link DEFAULT_BEAT_LOST_GRACE_MS}, and any beat that succeeds clears it.
2812
+ * Finally, an independent HTTP request must fail to confirm that this same
2813
+ * application's dev server is healthy.
2814
+ *
2815
+ * What is left is a request channel that has been silent for minutes on a
2816
+ * socket that never closed, with a turn waiting on it - which without this
2817
+ * waits out the whole turn budget.
2818
+ *
2819
+ * This is the only trigger that reaches a half-open socket. An unsolicited
2820
+ * close is the ordinary one and never arrives for a connection that stays up
2821
+ * with nothing behind it, and the SABS `session: terminated` event cannot
2822
+ * reach a gateway with no inbound route - which is every gateway run on a
2823
+ * laptop, where a loopback public URL leaves SABS no way to post at all.
2824
+ *
2825
+ * The socket is deliberately left in place. Whether a connection proven
2826
+ * wedged should also be torn down rather than handed to the next prompt is a
2827
+ * separate question from whether this turn should keep waiting on it.
2828
+ */
2829
+ abandonTurnOnBeatLost(held, heartbeat, turn, observed) {
2830
+ const applicationId = held.applicationId;
2831
+ if (held.dropping || this.held.get(applicationId) !== held) {
2832
+ return;
2833
+ }
2834
+ // The same rule the disconnect and SABS paths use: settle only a turn that
2835
+ // has not settled itself. A finished turn's socket keeps beating through
2836
+ // the post-turn grace, so a sandbox reaped in that window must not
2837
+ // overwrite the outcome the caller already has.
2838
+ if (turn &&
2839
+ this.turns.get(applicationId) === turn &&
2840
+ turn.held === held &&
2841
+ !turn.finished &&
2842
+ !turn.collector.status()) {
2843
+ console.warn("gateway clark turn abandoned", {
2844
+ applicationId,
2845
+ beatFailures: heartbeat.failures,
2846
+ beatLapses: heartbeat.lapses,
2847
+ beatsSent: heartbeat.sent,
2848
+ lastBeatError: heartbeat.lastError,
2849
+ reason: "the live-edit socket stopped answering",
2850
+ });
2851
+ this.settleAsLiveEditTerminated(turn, describeHeartbeat(heartbeat, Date.now()) +
2852
+ " The live-edit health check could not confirm the same app was healthy.");
2853
+ return;
2854
+ }
2855
+ if (observed &&
2856
+ this.observedTurns.get(applicationId) === observed &&
2857
+ observed.held === held &&
2858
+ !observed.finished) {
2859
+ this.settleObservedTurn(observed, "detached");
2860
+ this.armObservedTurnRelease(applicationId, observed);
2861
+ }
2862
+ }
2863
+ /**
2864
+ * Records a dead live edit as the turn's outcome and ends every wait on it.
2865
+ * `evidence` is appended to the reply because it is the only account of the
2866
+ * cause that reaches the caller.
2867
+ */
2868
+ settleAsLiveEditTerminated(turn, evidence) {
2869
+ this.settleTurn(turn, {
2870
+ reply: LIVE_EDIT_TERMINATED_REPLY + evidence,
2871
+ generationStatus: "live_edit_terminated",
2872
+ viewStatus: turn.collector.viewableStatus(),
2873
+ });
2874
+ }
2875
+ /**
2876
+ * Decides what a close the gateway did not ask for actually means, by going
2877
+ * back for the live edit rather than inferring from the close.
2878
+ *
2879
+ * The reattach machinery exists for exactly this: a turn whose socket went
2880
+ * away is one the gateway can move onto a replacement. Abandoning here is
2881
+ * what used to make that unreachable, and it charged a recoverable blip the
2882
+ * whole turn - the caller is told to send the prompt again, and the work
2883
+ * already done in it is discarded.
2884
+ *
2885
+ * Each of the three outcomes is a different thing to tell the caller:
2886
+ *
2887
+ * - taken over: nothing to tell. The turn carries on where it got to.
2888
+ * - unreachable for the whole grace: the termination the close only hinted
2889
+ * at, now with the evidence to say so.
2890
+ * - reached but refused the turn: the sandbox is alive and building where
2891
+ * this gateway cannot watch it, which is `detached`. Calling that a death
2892
+ * would send the caller to retry a build that is still running.
2893
+ *
2894
+ * A reconnect that fails while SABS is still relaying the turn settles
2895
+ * nothing: the socket is one of two ways this turn reports, and the events
2896
+ * still arriving are the better account of whether the build is alive.
2897
+ *
2898
+ * Never throws: nothing awaits this, and every outcome it can reach belongs
2899
+ * on the turn rather than on the socket handler that started it.
2900
+ */
2901
+ async recoverTurn(input) {
2902
+ const applicationId = input.previous.applicationId;
2903
+ const turn = this.turns.get(applicationId);
2904
+ // A turn that has moved on from this session, or already has an outcome,
2905
+ // has nothing riding on the socket that just closed.
2906
+ if (!turn || turn.finished || turn.held !== input.previous) {
2907
+ return;
2908
+ }
2909
+ const heartbeat = input.previous.heartbeat.account;
2910
+ const heardAt = turn.collector.lastEventAt();
2911
+ if (!this.takeReconnectAllowance(turn, heardAt)) {
2912
+ console.warn("gateway clark turn reconnects exhausted", {
2913
+ applicationId,
2914
+ attempts: turn.reconnects,
2915
+ closeCode: input.close.code,
2916
+ });
2917
+ this.abandonTurnOnDisconnect(applicationId, input.close, heartbeat);
2918
+ return;
2919
+ }
2920
+ this.note(turn, "Reconnecting to the Superblocks live-edit session");
2921
+ const outcome = await this.takeOverTurn({
2922
+ deadline: Date.now() +
2923
+ (this.options.reconnectGraceMs ?? DEFAULT_RECONNECT_GRACE_MS),
2924
+ previous: input.previous,
2925
+ turn,
2926
+ });
2927
+ if (turn.finished || outcome.kind === "stale") {
2928
+ return;
2929
+ }
2930
+ if (outcome.kind === "took_over") {
2931
+ this.note(turn, "Reconnected to the Superblocks live-edit session");
2932
+ return;
2933
+ }
2934
+ // TODO(ENG-5922): nothing tries to reconnect again from here. A turn kept
2935
+ // alive on the relay reaches its terminal state with no socket, so the
2936
+ // checkpoint that would commit Clark's work cannot land and the turn
2937
+ // completes without one.
2938
+ if (turn.collector.lastEventAt() > heardAt) {
2939
+ console.warn("gateway clark turn kept on the relay", {
2940
+ applicationId,
2941
+ closeCode: input.close.code,
2942
+ outcome: outcome.kind,
2943
+ });
2944
+ this.note(turn, "Lost the live-edit socket; still following the build through Superblocks");
2945
+ return;
2946
+ }
2947
+ if (outcome.kind === "unreachable") {
2948
+ this.abandonTurnOnDisconnect(applicationId, input.close, heartbeat);
2949
+ return;
2950
+ }
2951
+ console.warn("gateway clark turn not handed back", {
2952
+ applicationId,
2953
+ closeCode: input.close.code,
2954
+ });
2955
+ this.settleTurn(turn, {
2956
+ reply: turn.collector.reply(),
2957
+ generationStatus: "detached",
2958
+ viewStatus: turn.collector.viewableStatus(),
2959
+ ...decisionFields(turn.collector.decision()),
2960
+ });
2961
+ }
2962
+ /**
2963
+ * Ends a prior turn for `applicationId` before a newer prompt claims the map
2964
+ * slot. Callers polling the old turn see `cancelled`; its runTurn exits via
2965
+ * AbortSignal and must not keep listening or arming release on the held
2966
+ * session.
2967
+ */
2968
+ supersedeTurn(applicationId) {
2969
+ const prior = this.turns.get(applicationId);
2970
+ this.turns.delete(applicationId);
2971
+ if (!prior) {
2972
+ return;
2973
+ }
2974
+ this.cancelTurn(prior);
2975
+ }
2976
+ cancelTurn(turn) {
2977
+ turn.finished ??= {
2978
+ reply: "This turn was replaced by a newer prompt on the same app, so its work was cancelled.",
2979
+ generationStatus: "cancelled",
2980
+ viewStatus: turn.collector.viewableStatus(),
2981
+ };
2982
+ turn.held.listeners.delete(turn.listener);
2983
+ this.held.get(turn.held.applicationId)?.listeners.delete(turn.listener);
2984
+ turn.collector.dispose();
2985
+ turn.abort.abort();
2986
+ this.wake(turn);
2987
+ }
2988
+ discardUnstartedTurn(turn) {
2989
+ if (turn.held.pendingTurn === turn) {
2990
+ delete turn.held.pendingTurn;
2991
+ }
2992
+ turn.held.listeners.delete(turn.listener);
2993
+ turn.collector.dispose();
2994
+ turn.abort.abort();
2995
+ this.wake(turn);
2996
+ }
2997
+ armPostTurnRelease(held, turn) {
2998
+ this.clearTimers(held);
2999
+ const delayMs = this.options.releaseAfterTurnMs ?? DEFAULT_RELEASE_AFTER_TURN_MS;
3000
+ held.idleTimer = setTimeout(() => {
3001
+ const active = this.turns.get(held.applicationId);
3002
+ if (active && active !== turn && !active.finished) {
3003
+ return;
3004
+ }
3005
+ if (this.held.get(held.applicationId) === held) {
3006
+ this.drop(held);
3007
+ }
3008
+ }, delayMs);
3009
+ held.idleTimer.unref?.();
3010
+ }
3011
+ armKeepalive(held) {
3012
+ if (held.keepaliveTimer) {
3013
+ clearInterval(held.keepaliveTimer);
3014
+ held.keepaliveTimer = undefined;
3015
+ }
3016
+ if (!held.keepalive) {
3017
+ return;
3018
+ }
3019
+ const keepaliveMs = this.options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS;
3020
+ held.keepaliveTimer = setInterval(() => {
3021
+ void held.keepalive?.().catch(() => {
3022
+ // Best-effort: a failed connect refresh must not tear down the held
3023
+ // session socket; the next MCP call will reconnect if needed.
3024
+ });
3025
+ }, keepaliveMs);
3026
+ held.keepaliveTimer.unref?.();
3027
+ }
3028
+ /**
3029
+ * Clears the turn timers only. The beat is stopped separately on purpose: it
3030
+ * belongs to the socket and outlives these, which the post-turn grace period
3031
+ * resets.
3032
+ */
3033
+ clearIdleTimer(held) {
3034
+ if (held.idleTimer) {
3035
+ clearTimeout(held.idleTimer);
3036
+ held.idleTimer = undefined;
3037
+ }
3038
+ }
3039
+ clearTimers(held) {
3040
+ this.clearIdleTimer(held);
3041
+ if (held.keepaliveTimer) {
3042
+ clearInterval(held.keepaliveTimer);
3043
+ held.keepaliveTimer = undefined;
3044
+ }
3045
+ }
3046
+ drop(held, options = {}) {
3047
+ console.error("gateway held session released", {
3048
+ applicationId: held.applicationId,
3049
+ });
3050
+ held.dropping = true;
3051
+ const observed = this.observedTurns.get(held.applicationId);
3052
+ if (observed?.held === held &&
3053
+ !(options.preserveFinishedObserved && observed.finished)) {
3054
+ this.forgetObservedTurn(held.applicationId);
3055
+ }
3056
+ this.clearTimers(held);
3057
+ held.heartbeat.stop();
3058
+ // Only if it is still the current session: a reattach registers its
3059
+ // replacement before closing this one, and unregistering by app id alone
3060
+ // would take the replacement out of the map with it.
3061
+ if (this.held.get(held.applicationId) === held) {
3062
+ this.held.delete(held.applicationId);
3063
+ }
3064
+ try {
3065
+ held.socket.close("held session released");
3066
+ }
3067
+ catch {
3068
+ // Socket may already be closed.
3069
+ }
3070
+ try {
3071
+ if (held.ws.readyState === WebSocket.OPEN ||
3072
+ held.ws.readyState === WebSocket.CONNECTING) {
3073
+ held.ws.close();
3074
+ }
3075
+ }
3076
+ catch {
3077
+ // Ignore double-close.
3078
+ }
3079
+ }
3080
+ editorContextFor(held) {
3081
+ if (!held?.apiKey ||
3082
+ !held.organizationId ||
3083
+ !held.sessionToken ||
3084
+ !this.options.server ||
3085
+ !held.orchestrator) {
3086
+ return undefined;
3087
+ }
3088
+ return {
3089
+ apiKey: held.apiKey,
3090
+ applicationId: held.applicationId,
3091
+ branchName: held.branchName,
3092
+ organizationId: held.organizationId,
3093
+ orchestrator: held.orchestrator,
3094
+ profileKey: held.profileKey ?? pickEditorProfileKey(this.options.profileKey, []),
3095
+ server: this.options.server,
3096
+ sessionToken: held.sessionToken,
3097
+ waitUntilFilesSynced: async () => {
3098
+ const collector = this.turns.get(held.applicationId)?.collector;
3099
+ if (!collector) {
3100
+ return true;
3101
+ }
3102
+ return await collector.waitUntilInSync({
3103
+ timeoutMs: this.options.readyWaitMs ?? DEFAULT_READY_WAIT_MS,
3104
+ });
3105
+ },
3106
+ };
3107
+ }
3108
+ /**
3109
+ * Context the sandbox needs before it will let Clark drive a browser.
3110
+ *
3111
+ * Absent when headless testing is off or the session is missing something
3112
+ * the app shell cannot boot without — Clark then runs the turn with its
3113
+ * ordinary tools rather than failing it.
3114
+ */
3115
+ browserContextFor(held) {
3116
+ if (!this.options.testingEnabled) {
3117
+ return undefined;
3118
+ }
3119
+ return this.buildHeldBrowserContext(held);
3120
+ }
3121
+ /**
3122
+ * Library iframe bootstrap from a held session. Used for Gateway's own
3123
+ * live-canvas screenshot even when Clark's in-sandbox Playwright is off.
3124
+ */
3125
+ buildHeldBrowserContext(held) {
3126
+ if (!held.orchestratorUrl) {
3127
+ console.error("gateway live canvas skipped: missing_orchestrator_url", {
3128
+ applicationId: held.applicationId,
3129
+ });
3130
+ return undefined;
3131
+ }
3132
+ if (!this.options.uiBaseUrl) {
3133
+ console.error("gateway live canvas skipped: missing_ui_base_url", {
3134
+ applicationId: held.applicationId,
3135
+ });
3136
+ return undefined;
3137
+ }
3138
+ if (!held.sessionToken) {
3139
+ console.error("gateway live canvas skipped: missing_session_token", {
3140
+ applicationId: held.applicationId,
3141
+ });
3142
+ return undefined;
3143
+ }
3144
+ return buildBrowserContext({
3145
+ ...(held.apiKey ? { apiKey: held.apiKey } : {}),
3146
+ applicationId: held.applicationId,
3147
+ liveEditUrl: held.liveEdit.url,
3148
+ orchestratorUrl: held.orchestratorUrl,
3149
+ organizationId: held.organizationId,
3150
+ peerId: held.peerId,
3151
+ profileKey: held.profileKey,
3152
+ profiles: held.profiles,
3153
+ sessionToken: held.sessionToken,
3154
+ uiBaseUrl: this.options.uiBaseUrl,
3155
+ userId: held.userId,
3156
+ });
3157
+ }
3158
+ /**
3159
+ * Resolves a real org profile key once per held session. Sending the legacy
3160
+ * placeholder `default` makes the orchestrator fail every integration query
3161
+ * with "Invalid profile specified" / HTTP 500.
3162
+ */
3163
+ async resolveHeldProfileKey(held) {
3164
+ if (held.profileKey || !held.apiKey || !held.organizationId) {
3165
+ return;
3166
+ }
3167
+ if (!this.options.server) {
3168
+ held.profileKey = pickEditorProfileKey(this.options.profileKey, []);
3169
+ return;
3170
+ }
3171
+ try {
3172
+ const profiles = await this.options.server.getOrganizationProfiles({
3173
+ apiKey: held.apiKey,
3174
+ organizationId: held.organizationId,
3175
+ });
3176
+ held.profiles = profiles;
3177
+ held.profileKey = pickEditorProfileKey(this.options.profileKey, profiles);
3178
+ console.error("gateway resolved integration profile", {
3179
+ applicationId: held.applicationId,
3180
+ organizationId: held.organizationId,
3181
+ profileKey: held.profileKey,
3182
+ preferred: this.options.profileKey,
3183
+ });
3184
+ }
3185
+ catch (error) {
3186
+ held.profileKey = pickEditorProfileKey(this.options.profileKey, []);
3187
+ console.warn("gateway failed to list org profiles; using fallback", {
3188
+ applicationId: held.applicationId,
3189
+ message: error instanceof Error ? error.message : String(error),
3190
+ profileKey: held.profileKey,
3191
+ });
3192
+ }
3193
+ }
3194
+ /**
3195
+ * Discovers the orchestrator / agent base URL the way the browser does:
3196
+ * Server `/users/me` agents filtered by org agentType, Active status, and
3197
+ * profile tags. Operators never set this URL.
3198
+ */
3199
+ async resolveHeldOrchestrator(held) {
3200
+ if (held.orchestrator || held.orchestratorUrl) {
3201
+ return;
3202
+ }
3203
+ if (this.options.orchestrator || this.options.orchestratorUrl) {
3204
+ held.orchestrator = this.options.orchestrator;
3205
+ held.orchestratorUrl = this.options.orchestratorUrl;
3206
+ return;
3207
+ }
3208
+ if (!held.apiKey || !held.organizationId || !this.options.server) {
3209
+ return;
3210
+ }
3211
+ const profileKey = held.profileKey ?? pickEditorProfileKey(this.options.profileKey, []);
3212
+ try {
3213
+ const inventory = await this.options.server.getAgentInventory({
3214
+ apiKey: held.apiKey,
3215
+ organizationId: held.organizationId,
3216
+ });
3217
+ const orchestratorUrl = pickOrchestratorBaseUrl({
3218
+ agentType: inventory.agentType,
3219
+ agents: inventory.agents,
3220
+ profileKey,
3221
+ });
3222
+ held.orchestratorUrl = orchestratorUrl;
3223
+ held.orchestrator = new HttpOrchestratorClient(orchestratorUrl);
3224
+ console.error("gateway resolved orchestrator url", {
3225
+ applicationId: held.applicationId,
3226
+ organizationId: held.organizationId,
3227
+ orchestratorUrl,
3228
+ profileKey,
3229
+ });
3230
+ }
3231
+ catch (error) {
3232
+ console.warn("gateway failed to discover orchestrator url", {
3233
+ applicationId: held.applicationId,
3234
+ message: error instanceof Error ? error.message : String(error),
3235
+ organizationId: held.organizationId,
3236
+ profileKey,
3237
+ });
3238
+ }
3239
+ }
3240
+ }
3241
+ //# sourceMappingURL=websocket-session-peer.js.map