@superblocksteam/gateway 2.0.161-next.0 → 2.0.162-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +135 -39
  2. package/dist/capabilities/lifecycle.d.ts +46 -29
  3. package/dist/capabilities/lifecycle.js +1731 -843
  4. package/dist/capabilities/lifecycle.js.map +1 -1
  5. package/dist/capabilities/query-integration.d.ts +14 -0
  6. package/dist/capabilities/query-integration.js +172 -0
  7. package/dist/capabilities/query-integration.js.map +1 -0
  8. package/dist/capabilities/source-files-archive.d.ts +11 -0
  9. package/dist/capabilities/source-files-archive.js +54 -0
  10. package/dist/capabilities/source-files-archive.js.map +1 -0
  11. package/dist/capabilities/types.d.ts +197 -119
  12. package/dist/capabilities/types.js +45 -6
  13. package/dist/capabilities/types.js.map +1 -1
  14. package/dist/capture/browser-contract.d.ts +15 -5
  15. package/dist/capture/browser-contract.js +13 -3
  16. package/dist/capture/browser-contract.js.map +1 -1
  17. package/dist/capture/browser-instructions.d.ts +3 -2
  18. package/dist/capture/browser-instructions.js +3 -2
  19. package/dist/capture/browser-instructions.js.map +1 -1
  20. package/dist/capture/mode.d.ts +8 -1
  21. package/dist/capture/mode.js +22 -2
  22. package/dist/capture/mode.js.map +1 -1
  23. package/dist/config.d.ts +22 -23
  24. package/dist/config.js +13 -12
  25. package/dist/config.js.map +1 -1
  26. package/dist/deps.d.ts +7 -7
  27. package/dist/index.d.ts +2 -4
  28. package/dist/index.js +2 -4
  29. package/dist/index.js.map +1 -1
  30. package/dist/integrations/read-only-postgres-query.d.ts +2 -0
  31. package/dist/integrations/read-only-postgres-query.js +164 -0
  32. package/dist/integrations/read-only-postgres-query.js.map +1 -0
  33. package/dist/main.js +1 -1
  34. package/dist/main.js.map +1 -1
  35. package/dist/playwright/ensure-chromium.d.ts +5 -7
  36. package/dist/playwright/ensure-chromium.js +13 -13
  37. package/dist/playwright/ensure-chromium.js.map +1 -1
  38. package/dist/preview/capture-screenshot.d.ts +30 -4
  39. package/dist/preview/capture-screenshot.js +67 -17
  40. package/dist/preview/capture-screenshot.js.map +1 -1
  41. package/dist/preview/viewer-url.js +3 -1
  42. package/dist/preview/viewer-url.js.map +1 -1
  43. package/dist/process/fault-barrier.js +1 -1
  44. package/dist/process/fault-barrier.js.map +1 -1
  45. package/dist/sabs/agent-facing-text.d.ts +2 -0
  46. package/dist/sabs/agent-facing-text.js +56 -2
  47. package/dist/sabs/agent-facing-text.js.map +1 -1
  48. package/dist/sabs/app-state.d.ts +126 -0
  49. package/dist/sabs/app-state.js +332 -0
  50. package/dist/sabs/app-state.js.map +1 -0
  51. package/dist/sabs/awaited-decision.d.ts +49 -0
  52. package/dist/sabs/awaited-decision.js +129 -0
  53. package/dist/sabs/awaited-decision.js.map +1 -0
  54. package/dist/sabs/editor-client-methods.d.ts +23 -5
  55. package/dist/sabs/editor-client-methods.js +45 -4
  56. package/dist/sabs/editor-client-methods.js.map +1 -1
  57. package/dist/sabs/editor-socket.d.ts +85 -0
  58. package/dist/sabs/editor-socket.js +67 -0
  59. package/dist/sabs/editor-socket.js.map +1 -0
  60. package/dist/sabs/session-peer.d.ts +119 -82
  61. package/dist/sabs/session-peer.js +10 -1
  62. package/dist/sabs/session-peer.js.map +1 -1
  63. package/dist/sabs/streamed-reply.d.ts +45 -0
  64. package/dist/sabs/streamed-reply.js +125 -0
  65. package/dist/sabs/streamed-reply.js.map +1 -0
  66. package/dist/sabs/turn-collector.d.ts +50 -35
  67. package/dist/sabs/turn-collector.js +110 -176
  68. package/dist/sabs/turn-collector.js.map +1 -1
  69. package/dist/sabs/websocket-session-peer.d.ts +104 -69
  70. package/dist/sabs/websocket-session-peer.js +1329 -475
  71. package/dist/sabs/websocket-session-peer.js.map +1 -1
  72. package/dist/server/client.d.ts +41 -9
  73. package/dist/server/client.js +126 -21
  74. package/dist/server/client.js.map +1 -1
  75. package/dist/start.d.ts +1 -1
  76. package/dist/start.js +30 -8
  77. package/dist/start.js.map +1 -1
  78. package/dist/stores/memory.d.ts +46 -0
  79. package/dist/stores/memory.js +163 -0
  80. package/dist/stores/memory.js.map +1 -0
  81. package/dist/stores/types.d.ts +92 -0
  82. package/dist/stores/types.js +11 -0
  83. package/dist/stores/types.js.map +1 -0
  84. package/dist/telemetry/mcp-client.d.ts +6 -1
  85. package/dist/telemetry/mcp-client.js +100 -4
  86. package/dist/telemetry/mcp-client.js.map +1 -1
  87. package/dist/telemetry/metrics.d.ts +12 -3
  88. package/dist/telemetry/metrics.js +60 -14
  89. package/dist/telemetry/metrics.js.map +1 -1
  90. package/dist/telemetry/runtime.js +4 -6
  91. package/dist/telemetry/runtime.js.map +1 -1
  92. package/dist/transports/mcp/admin-tools.d.ts +8 -0
  93. package/dist/transports/mcp/admin-tools.js +115 -4
  94. package/dist/transports/mcp/admin-tools.js.map +1 -1
  95. package/dist/transports/mcp/app-status-html.d.ts +4 -8
  96. package/dist/transports/mcp/app-status-html.js +336 -128
  97. package/dist/transports/mcp/app-status-html.js.map +1 -1
  98. package/dist/transports/mcp/client-presentation.d.ts +16 -0
  99. package/dist/transports/mcp/client-presentation.js +13 -0
  100. package/dist/transports/mcp/client-presentation.js.map +1 -0
  101. package/dist/transports/mcp/cowork-editor-url.d.ts +6 -0
  102. package/dist/transports/mcp/cowork-editor-url.js +10 -0
  103. package/dist/transports/mcp/cowork-editor-url.js.map +1 -0
  104. package/dist/transports/mcp/decision-card-html.d.ts +26 -0
  105. package/dist/transports/mcp/decision-card-html.js +876 -0
  106. package/dist/transports/mcp/decision-card-html.js.map +1 -0
  107. package/dist/transports/mcp/decision-elicitation.d.ts +42 -0
  108. package/dist/transports/mcp/decision-elicitation.js +59 -0
  109. package/dist/transports/mcp/decision-elicitation.js.map +1 -1
  110. package/dist/transports/mcp/editor-document-probe.d.ts +4 -0
  111. package/dist/transports/mcp/editor-document-probe.js +35 -0
  112. package/dist/transports/mcp/editor-document-probe.js.map +1 -0
  113. package/dist/transports/mcp/editor-integration-setup-url.d.ts +14 -0
  114. package/dist/transports/mcp/editor-integration-setup-url.js +29 -0
  115. package/dist/transports/mcp/editor-integration-setup-url.js.map +1 -0
  116. package/dist/transports/mcp/format-tool-content.d.ts +13 -18
  117. package/dist/transports/mcp/format-tool-content.js +172 -13
  118. package/dist/transports/mcp/format-tool-content.js.map +1 -1
  119. package/dist/transports/mcp/instructions/index.d.ts +23 -0
  120. package/dist/transports/mcp/instructions/index.js +81 -0
  121. package/dist/transports/mcp/instructions/index.js.map +1 -0
  122. package/dist/transports/mcp/instructions/result.d.ts +18 -0
  123. package/dist/transports/mcp/instructions/result.js +58 -0
  124. package/dist/transports/mcp/instructions/result.js.map +1 -0
  125. package/dist/transports/mcp/instructions/tools/ask-user.d.ts +2 -0
  126. package/dist/transports/mcp/instructions/tools/ask-user.js +20 -0
  127. package/dist/transports/mcp/instructions/tools/ask-user.js.map +1 -0
  128. package/dist/transports/mcp/instructions/tools/build-app.d.ts +4 -0
  129. package/dist/transports/mcp/instructions/tools/build-app.js +54 -0
  130. package/dist/transports/mcp/instructions/tools/build-app.js.map +1 -0
  131. package/dist/transports/mcp/instructions/tools/check-app-progress.d.ts +3 -0
  132. package/dist/transports/mcp/instructions/tools/check-app-progress.js +79 -0
  133. package/dist/transports/mcp/instructions/tools/check-app-progress.js.map +1 -0
  134. package/dist/transports/mcp/instructions/tools/check-publish-progress.d.ts +3 -0
  135. package/dist/transports/mcp/instructions/tools/check-publish-progress.js +23 -0
  136. package/dist/transports/mcp/instructions/tools/check-publish-progress.js.map +1 -0
  137. package/dist/transports/mcp/instructions/tools/copy.d.ts +18 -0
  138. package/dist/transports/mcp/instructions/tools/copy.js +49 -0
  139. package/dist/transports/mcp/instructions/tools/copy.js.map +1 -0
  140. package/dist/transports/mcp/instructions/tools/create-integration.d.ts +4 -0
  141. package/dist/transports/mcp/instructions/tools/create-integration.js +41 -0
  142. package/dist/transports/mcp/instructions/tools/create-integration.js.map +1 -0
  143. package/dist/transports/mcp/instructions/tools/edit-app.d.ts +3 -0
  144. package/dist/transports/mcp/instructions/tools/edit-app.js +18 -0
  145. package/dist/transports/mcp/instructions/tools/edit-app.js.map +1 -0
  146. package/dist/transports/mcp/instructions/tools/get-app.d.ts +3 -0
  147. package/dist/transports/mcp/instructions/tools/get-app.js +49 -0
  148. package/dist/transports/mcp/instructions/tools/get-app.js.map +1 -0
  149. package/dist/transports/mcp/instructions/tools/get-integration-metadata.d.ts +2 -0
  150. package/dist/transports/mcp/instructions/tools/get-integration-metadata.js +13 -0
  151. package/dist/transports/mcp/instructions/tools/get-integration-metadata.js.map +1 -0
  152. package/dist/transports/mcp/instructions/tools/index.d.ts +7 -0
  153. package/dist/transports/mcp/instructions/tools/index.js +31 -0
  154. package/dist/transports/mcp/instructions/tools/index.js.map +1 -0
  155. package/dist/transports/mcp/instructions/tools/publish-app.d.ts +3 -0
  156. package/dist/transports/mcp/instructions/tools/publish-app.js +23 -0
  157. package/dist/transports/mcp/instructions/tools/publish-app.js.map +1 -0
  158. package/dist/transports/mcp/instructions/tools/start-app.d.ts +3 -0
  159. package/dist/transports/mcp/instructions/tools/start-app.js +22 -0
  160. package/dist/transports/mcp/instructions/tools/start-app.js.map +1 -0
  161. package/dist/transports/mcp/instructions/tools/upload-artifact.d.ts +2 -0
  162. package/dist/transports/mcp/instructions/tools/upload-artifact.js +13 -0
  163. package/dist/transports/mcp/instructions/tools/upload-artifact.js.map +1 -0
  164. package/dist/transports/mcp/mcp-app-brand-css.d.ts +1 -0
  165. package/dist/transports/mcp/mcp-app-brand-css.js +180 -0
  166. package/dist/transports/mcp/mcp-app-brand-css.js.map +1 -0
  167. package/dist/transports/mcp/mount.d.ts +14 -1
  168. package/dist/transports/mcp/mount.js +729 -172
  169. package/dist/transports/mcp/mount.js.map +1 -1
  170. package/dist/transports/mcp/native-browser-presence.d.ts +45 -0
  171. package/dist/transports/mcp/native-browser-presence.js +158 -0
  172. package/dist/transports/mcp/native-browser-presence.js.map +1 -0
  173. package/dist/transports/mcp/plan-approval.d.ts +44 -0
  174. package/dist/transports/mcp/plan-approval.js +179 -0
  175. package/dist/transports/mcp/plan-approval.js.map +1 -0
  176. package/dist/transports/mcp/session-directory.d.ts +7 -0
  177. package/dist/transports/mcp/session-directory.js +18 -0
  178. package/dist/transports/mcp/session-directory.js.map +1 -0
  179. package/dist/transports/mcp/tool-names.d.ts +2 -0
  180. package/dist/transports/mcp/tool-names.js +2 -0
  181. package/dist/transports/mcp/tool-names.js.map +1 -0
  182. package/package.json +16 -7
  183. package/skills/superblocks-build/SKILL.md +59 -0
  184. package/skills/superblocks-import/SKILL.md +125 -0
  185. package/dist/capabilities/import-prompt.d.ts +0 -11
  186. package/dist/capabilities/import-prompt.js +0 -96
  187. package/dist/capabilities/import-prompt.js.map +0 -1
  188. package/dist/capabilities/persisted-progress.d.ts +0 -46
  189. package/dist/capabilities/persisted-progress.js +0 -246
  190. package/dist/capabilities/persisted-progress.js.map +0 -1
  191. package/dist/events/cursor.d.ts +0 -43
  192. package/dist/events/cursor.js +0 -78
  193. package/dist/events/cursor.js.map +0 -1
  194. package/dist/events/memory-event-store.d.ts +0 -34
  195. package/dist/events/memory-event-store.js +0 -110
  196. package/dist/events/memory-event-store.js.map +0 -1
  197. package/dist/events/merge.d.ts +0 -23
  198. package/dist/events/merge.js +0 -97
  199. package/dist/events/merge.js.map +0 -1
  200. package/dist/events/normalized-collector.d.ts +0 -62
  201. package/dist/events/normalized-collector.js +0 -156
  202. package/dist/events/normalized-collector.js.map +0 -1
  203. package/dist/events/schema.d.ts +0 -9
  204. package/dist/events/schema.js +0 -93
  205. package/dist/events/schema.js.map +0 -1
  206. package/dist/events/snapshot.d.ts +0 -32
  207. package/dist/events/snapshot.js +0 -57
  208. package/dist/events/snapshot.js.map +0 -1
  209. package/dist/events/stream-key.d.ts +0 -2
  210. package/dist/events/stream-key.js +0 -31
  211. package/dist/events/stream-key.js.map +0 -1
  212. package/dist/events/types.d.ts +0 -179
  213. package/dist/events/types.js +0 -66
  214. package/dist/events/types.js.map +0 -1
  215. package/dist/resume/memory-progress-store.d.ts +0 -39
  216. package/dist/resume/memory-progress-store.js +0 -82
  217. package/dist/resume/memory-progress-store.js.map +0 -1
  218. package/dist/resume/memory-recent-app-store.d.ts +0 -14
  219. package/dist/resume/memory-recent-app-store.js +0 -27
  220. package/dist/resume/memory-recent-app-store.js.map +0 -1
  221. package/dist/resume/memory-turn-store.d.ts +0 -18
  222. package/dist/resume/memory-turn-store.js +0 -73
  223. package/dist/resume/memory-turn-store.js.map +0 -1
  224. package/dist/resume/progress-key.d.ts +0 -21
  225. package/dist/resume/progress-key.js +0 -58
  226. package/dist/resume/progress-key.js.map +0 -1
  227. package/dist/resume/stores.d.ts +0 -14
  228. package/dist/resume/stores.js +0 -18
  229. package/dist/resume/stores.js.map +0 -1
  230. package/dist/resume/types.d.ts +0 -124
  231. package/dist/resume/types.js +0 -13
  232. package/dist/resume/types.js.map +0 -1
@@ -1,27 +1,47 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFile, realpath, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { detectAttachmentArchiveType } from "@superblocksteam/shared";
4
+ import { detectAttachmentArchiveType, getAttachmentMimeTypeFromFileName, getTextAttachmentType, isSupportedAttachmentUploadType, } from "@superblocksteam/shared";
5
5
  import { buildBrowserDriverContract, needsBrowserDriverError, } from "../capture/browser-contract.js";
6
6
  import { resolveCaptureMode } from "../capture/mode.js";
7
- import { EMPTY_EVENT_CURSOR, parseEventCursor } from "../events/cursor.js";
7
+ import { gatewayDebug } from "../debug.js";
8
8
  import { playwrightChromiumPresent } from "../playwright/ensure-chromium.js";
9
- import { buildEmbedShellPreviewUrl, buildOpenablePreviewUrls, } from "../preview/viewer-url.js";
9
+ import { isTrustedEmbedOrigin, } from "../preview/capture-screenshot.js";
10
+ import { buildEmbedShellPreviewUrl, buildOpenablePreviewUrls, isHttpsUrl, } from "../preview/viewer-url.js";
11
+ import { PromptNotSentError, } from "../sabs/session-peer.js";
10
12
  import { ServerHttpError } from "../server/client.js";
11
13
  import { IdentityUnavailableError } from "../server/identity-reads.js";
12
- import { buildImportPrompt } from "./import-prompt.js";
13
- import { readPersistedProgress } from "./persisted-progress.js";
14
- import { buildStatusOf } from "./types.js";
14
+ import { archiveSourceFiles } from "./source-files-archive.js";
15
+ import { buildStatusOf, progressStatusOf } from "./types.js";
15
16
  import { IMPORT_ZIP_MAX_BYTES } from "./types.js";
16
17
  export { IMPORT_ZIP_MAX_BYTES } from "./types.js";
17
18
  const APPLICATION_CONTEXT_REQUIRED = "No applicationId provided and no recent application is available. Call start_app first or pass applicationId.";
19
+ const APPLICATION_CONTEXT_AMBIGUOUS = "No applicationId provided and more than one application was worked on recently. Pass the applicationId of the application this conversation is building.";
20
+ /**
21
+ * How long an application stays eligible to stand in for one a call did not
22
+ * name. A default argument is only useful while it is still plausibly what the
23
+ * caller meant: this process is shared by every chat in the client, so an
24
+ * unbounded fallback hands one conversation an application another one left
25
+ * behind.
26
+ */
27
+ const RECENT_APP_FALLBACK_WINDOW_MS = 30 * 60 * 1000;
18
28
  /**
19
29
  * Every app the gateway starts is a code-mode app (`createUsingReact`), which
20
30
  * the UI serves from `/code-mode` — the legacy `/applications/:id/edit` route
21
31
  * does not open it.
22
32
  */
23
- function editUrl(config, applicationId) {
24
- return `${config.uiBaseUrl.replace(/\/$/, "")}/code-mode/applications/edit/${applicationId}`;
33
+ function editUrl(config, applicationId, branch) {
34
+ const base = `${config.uiBaseUrl.replace(/\/$/, "")}/code-mode/applications/edit/${applicationId}`;
35
+ return branch
36
+ ? `${base}?${new URLSearchParams({ branch }).toString()}`
37
+ : base;
38
+ }
39
+ function fullscreenPreviewUrl(config, applicationId, branch) {
40
+ const params = new URLSearchParams({ fullscreen: "true" });
41
+ if (branch) {
42
+ params.set("branch", branch);
43
+ }
44
+ return `${editUrl(config, applicationId)}?${params.toString()}`;
25
45
  }
26
46
  function uiOrigin(config) {
27
47
  return config.uiBaseUrl.replace(/\/$/, "");
@@ -38,8 +58,8 @@ async function captureModeOf(ctx) {
38
58
  hostMcpConfigured: ctx.config.captureHostMcp,
39
59
  });
40
60
  }
41
- function libraryCaptureInput(ctx, applicationId) {
42
- const context = ctx.sessionPeer.libraryBrowserContext?.(applicationId);
61
+ function libraryCaptureInput(ctx, applicationId, branch) {
62
+ const context = ctx.sessionPeer.libraryBrowserContext(applicationId, branch);
43
63
  if (!context) {
44
64
  return undefined;
45
65
  }
@@ -92,14 +112,16 @@ function libraryCaptureInput(ctx, applicationId) {
92
112
  * live-edit when the in-memory hold is empty (MCP respawn / tool timeout).
93
113
  */
94
114
  async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch) {
95
- const existing = libraryCaptureInput(ctx, applicationId);
115
+ const existing = libraryCaptureInput(ctx, applicationId, branch);
96
116
  if (existing) {
97
117
  return existing;
98
118
  }
99
- if (!ctx.sessionPeer.holdLiveEdit) {
100
- console.error("gateway live canvas skipped: hold_live_edit_unavailable", {
101
- applicationId,
102
- });
119
+ if (ctx.appState.pendingStart({
120
+ applicationId,
121
+ ...(branch ? { branch } : {}),
122
+ organizationId: principal.organizationId,
123
+ userId: principal.userId,
124
+ })) {
103
125
  return undefined;
104
126
  }
105
127
  if (!(await gatewayChromiumIsPresent(ctx))) {
@@ -123,6 +145,13 @@ async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch)
123
145
  apiKey: principal.apiKey,
124
146
  applicationId,
125
147
  branch,
148
+ getLiveEditStatus: ({ liveEditId, signal }) => ctx.server.getLiveEditStatus({
149
+ apiKey: principal.apiKey,
150
+ applicationId,
151
+ branch,
152
+ liveEditId,
153
+ signal,
154
+ }),
126
155
  keepalive: async () => {
127
156
  await ctx.server.connectLiveEdit({
128
157
  apiKey: principal.apiKey,
@@ -130,6 +159,7 @@ async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch)
130
159
  branch,
131
160
  });
132
161
  },
162
+ liveEditId: session.liveEditId,
133
163
  liveEditUrl: session.liveEditUrl,
134
164
  organizationId: principal.organizationId,
135
165
  sessionToken,
@@ -143,7 +173,7 @@ async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch)
143
173
  });
144
174
  return undefined;
145
175
  }
146
- const live = libraryCaptureInput(ctx, applicationId);
176
+ const live = libraryCaptureInput(ctx, applicationId, branch);
147
177
  if (!live) {
148
178
  console.error("gateway live canvas skipped: context_incomplete_after_rehold", { applicationId });
149
179
  }
@@ -183,18 +213,19 @@ async function captureLiveLibraryIfNeeded(ctx, applicationId, live) {
183
213
  return { needsBrowserDriver: true };
184
214
  }
185
215
  }
186
- async function browserContractFor(ctx, applicationId) {
216
+ async function browserContractFor(ctx, directEditUrl) {
187
217
  return buildBrowserDriverContract({
188
218
  captureMode: await captureModeOf(ctx),
189
- editUrl: editUrl(ctx.config, applicationId),
219
+ editUrl: directEditUrl,
220
+ ...(ctx.hostBrowserTools ? { hostBrowserTools: ctx.hostBrowserTools } : {}),
190
221
  isolated: ctx.config.captureHostIsolated,
191
222
  uiOrigin: uiOrigin(ctx.config),
192
223
  });
193
224
  }
194
- async function withBrowser(ctx, applicationId, value) {
225
+ async function withBrowser(ctx, value) {
195
226
  return {
196
227
  ...value,
197
- browser: await browserContractFor(ctx, applicationId),
228
+ browser: await browserContractFor(ctx, value.editUrl),
198
229
  };
199
230
  }
200
231
  /**
@@ -205,6 +236,9 @@ async function withBrowser(ctx, applicationId, value) {
205
236
  function publishUrl(config, applicationId) {
206
237
  return `${config.uiBaseUrl.replace(/\/$/, "")}/code-mode/applications/${applicationId}`;
207
238
  }
239
+ function publishDetailsUrl(config, applicationId, commitId) {
240
+ return `${editUrl(config, applicationId)}?${new URLSearchParams({ commitId }).toString()}`;
241
+ }
208
242
  /**
209
243
  * Where a built, undeployed commit can be opened — what the editor's Preview
210
244
  * button hands the browser. Distinct from {@link publishUrl}: this serves a
@@ -334,11 +368,11 @@ async function finalAppName(ctx, input) {
334
368
  prompt: input.prompt,
335
369
  })) ?? input.name);
336
370
  }
337
- async function elicitStepUp(ctx, idempotencyKey, applicationId) {
371
+ async function elicitStepUp(ctx, idempotencyKey, applicationId, branch) {
338
372
  if (!ctx.config.captureHostMcp) {
339
373
  return needsBrowserDriverError();
340
374
  }
341
- const existing = await ctx.turns.getByIdempotencyKey(idempotencyKey);
375
+ const existing = ctx.stepUpTurns.getByIdempotencyKey(idempotencyKey);
342
376
  if (existing?.status === "ready") {
343
377
  return {
344
378
  kind: "error",
@@ -346,7 +380,7 @@ async function elicitStepUp(ctx, idempotencyKey, applicationId) {
346
380
  message: "Step-up authentication completed. Re-invoke the capability to continue.",
347
381
  };
348
382
  }
349
- const browser = await browserContractFor(ctx, applicationId);
383
+ const browser = await browserContractFor(ctx, editUrl(ctx.config, applicationId, branch));
350
384
  if (existing?.status === "pending") {
351
385
  return {
352
386
  kind: "auth_required",
@@ -361,7 +395,7 @@ async function elicitStepUp(ctx, idempotencyKey, applicationId) {
361
395
  const turnId = randomUUID();
362
396
  const callbackToken = randomUUID();
363
397
  const authUrl = uiOrigin(ctx.config);
364
- const turn = await ctx.turns.create({
398
+ const turn = ctx.stepUpTurns.create({
365
399
  authUrl,
366
400
  callbackToken,
367
401
  idempotencyKey,
@@ -383,28 +417,47 @@ async function elicitStepUp(ctx, idempotencyKey, applicationId) {
383
417
  },
384
418
  };
385
419
  }
386
- export async function resolveApplicationId(ctx, principal, applicationId) {
420
+ export async function resolveApplicationId(ctx, principal, applicationId,
421
+ /**
422
+ * `unambiguous` declines to guess while a second application is also in
423
+ * play. Reserved for calls that write: acting on the wrong application is
424
+ * cheap to undo for a status poll and not cheap at all for an edit.
425
+ */
426
+ options = {}) {
387
427
  if (applicationId?.trim()) {
388
428
  return { kind: "ok", value: applicationId.trim() };
389
429
  }
390
- const recent = await ctx.recentApps.get({
430
+ const key = {
391
431
  organizationId: principal.organizationId,
392
432
  userId: principal.userId,
393
- });
394
- if (!recent?.applicationId) {
433
+ };
434
+ const recent = options.unambiguous
435
+ ? ctx.recentApps.recentUnambiguous(key, RECENT_APP_FALLBACK_WINDOW_MS)
436
+ : ctx.recentApps.recent(key, RECENT_APP_FALLBACK_WINDOW_MS);
437
+ if (!recent) {
438
+ const ambiguous = options.unambiguous === true &&
439
+ ctx.recentApps.recent(key, RECENT_APP_FALLBACK_WINDOW_MS) !== undefined;
395
440
  return {
396
441
  kind: "error",
397
442
  code: "application_context_required",
398
- message: APPLICATION_CONTEXT_REQUIRED,
443
+ message: ambiguous
444
+ ? APPLICATION_CONTEXT_AMBIGUOUS
445
+ : APPLICATION_CONTEXT_REQUIRED,
399
446
  };
400
447
  }
448
+ // Leaning on the fallback is itself working on the application. Without this
449
+ // a build polled for longer than the window would age out mid-poll, and the
450
+ // progress call that had been answering all along would start reporting no
451
+ // application context. `set` keeps the branch already recorded for it.
452
+ ctx.recentApps.set({ ...key, applicationId: recent.applicationId });
401
453
  return { kind: "ok", value: recent.applicationId };
402
454
  }
403
- async function rememberApplication(ctx, principal, applicationId) {
404
- await ctx.recentApps.set({
455
+ function rememberApplication(ctx, principal, applicationId, branch) {
456
+ ctx.recentApps.set({
405
457
  applicationId,
406
458
  organizationId: principal.organizationId,
407
459
  userId: principal.userId,
460
+ ...(branch ? { branch } : {}),
408
461
  });
409
462
  }
410
463
  function sleep(ms) {
@@ -435,15 +488,60 @@ function sleepUntilAbort(ms, signal) {
435
488
  signal.addEventListener("abort", done, { once: true });
436
489
  });
437
490
  }
491
+ function readDeployOutcome(status) {
492
+ if (status.status === "DEPLOYED") {
493
+ return { kind: "deployed" };
494
+ }
495
+ if (status.status === "FAILED") {
496
+ return {
497
+ kind: "failed",
498
+ message: status.error ?? "The Superblocks API could not deploy the app.",
499
+ };
500
+ }
501
+ if (status.status === "AWAITING_ACK") {
502
+ return {
503
+ kind: "needs_acknowledgement",
504
+ message: `${status.error ?? "Policy checks passed with advisory findings."} Publishing is on hold until they are reviewed in Superblocks, or until this is published again with the findings acknowledged.`,
505
+ };
506
+ }
507
+ // "none" is not evidence of a successful deploy: the queue row may be
508
+ // missing, cleaned up after failure, or the deploy path may be unavailable.
509
+ // It counts as still running, and never as live.
510
+ return { kind: "running", status: status.status };
511
+ }
512
+ function deployStatusReadIsTransient(error) {
513
+ return (!(error instanceof ServerHttpError) ||
514
+ error.status === 429 ||
515
+ error.status >= 500);
516
+ }
517
+ /** The two outcomes that end a publish, as the error the caller reads. */
518
+ function deployFailure(outcome, message) {
519
+ return {
520
+ kind: "error",
521
+ code: outcome.kind === "failed"
522
+ ? "publish_failed"
523
+ : "publish_needs_acknowledgement",
524
+ message,
525
+ };
526
+ }
527
+ /**
528
+ * Follows a queued deploy until it lands, fails, or the call runs out of the
529
+ * time a host allows it.
530
+ *
531
+ * Running out is not a failure: `settled` false means the deploy is still
532
+ * going and the caller should hand it back with pacing rather than report a
533
+ * publish that worked as a timeout (ENG-6172).
534
+ */
438
535
  async function waitForDeployLive(ctx, input) {
439
- const deadline = Date.now() + ctx.config.publishWaitTimeoutMs;
440
536
  let lastStatus = "queued";
441
- while (Date.now() < deadline) {
442
- if (input.signal?.aborted) {
537
+ while (nowMs(ctx) < input.deadlineAtMs) {
538
+ if (ctx.signal?.aborted) {
443
539
  return {
444
- kind: "error",
445
- code: "publish_aborted",
446
- message: "Publish wait was cancelled.",
540
+ kind: "ok",
541
+ value: {
542
+ lastStatus: lastStatus === "none" ? "queued" : lastStatus,
543
+ settled: false,
544
+ },
447
545
  };
448
546
  }
449
547
  let status;
@@ -452,9 +550,32 @@ async function waitForDeployLive(ctx, input) {
452
550
  apiKey: input.apiKey,
453
551
  applicationId: input.applicationId,
454
552
  commitId: input.commitId,
553
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
554
+ timeoutMs: Math.max(1, input.deadlineAtMs - nowMs(ctx)),
455
555
  });
456
556
  }
457
557
  catch (error) {
558
+ if (ctx.signal?.aborted) {
559
+ return {
560
+ kind: "ok",
561
+ value: {
562
+ lastStatus: lastStatus === "none" ? "queued" : lastStatus,
563
+ settled: false,
564
+ },
565
+ };
566
+ }
567
+ if (nowMs(ctx) >= input.deadlineAtMs) {
568
+ return {
569
+ kind: "ok",
570
+ value: { lastStatus: "queued", settled: false },
571
+ };
572
+ }
573
+ if (deployStatusReadIsTransient(error)) {
574
+ return {
575
+ kind: "ok",
576
+ value: { lastStatus: "queued", settled: false },
577
+ };
578
+ }
458
579
  return {
459
580
  kind: "error",
460
581
  code: "publish_failed",
@@ -462,38 +583,30 @@ async function waitForDeployLive(ctx, input) {
462
583
  };
463
584
  }
464
585
  lastStatus = status.status;
465
- if (status.status === "DEPLOYED") {
466
- ctx.onProgress?.({ message: "App is live", type: "progress" });
467
- return { kind: "ok", value: {} };
586
+ const outcome = readDeployOutcome(status);
587
+ if (outcome.kind === "running") {
588
+ if (outcome.status === "none") {
589
+ ctx.appState.noteMissingPublishStatus(input.applicationId, input.publishClaim, nowMs(ctx));
590
+ }
591
+ else {
592
+ ctx.appState.noteVisiblePublishStatus(input.applicationId, input.publishClaim);
593
+ }
468
594
  }
469
- if (status.status === "FAILED") {
470
- return {
471
- kind: "error",
472
- code: "publish_failed",
473
- message: status.error ?? "The Superblocks API could not deploy the app.",
474
- };
595
+ if (outcome.kind === "deployed") {
596
+ ctx.onProgress?.({ message: "App is live", type: "progress" });
597
+ return { kind: "ok", value: { lastStatus, settled: true } };
475
598
  }
476
- if (status.status === "AWAITING_ACK") {
477
- return {
478
- kind: "error",
479
- code: "publish_needs_acknowledgement",
480
- message: `${status.error ?? "Policy checks passed with advisory findings."} Publishing is on hold until they are reviewed in Superblocks, or until this is published again with the findings acknowledged.`,
481
- };
599
+ if (outcome.kind !== "running") {
600
+ ctx.appState.clearPublishInFlight(input.applicationId, input.publishClaim);
601
+ return deployFailure(outcome, outcome.message);
482
602
  }
483
- // "none" is not evidence of a successful deploy: the queue row may be
484
- // missing, cleaned up after failure, or the deploy path may be unavailable.
485
- // Keep waiting until DEPLOYED, FAILED, AWAITING_ACK, or timeout.
486
603
  ctx.onProgress?.({
487
- message: `Waiting for deploy (${status.status})…`,
604
+ message: `Waiting for deploy (${outcome.status})…`,
488
605
  type: "progress",
489
606
  });
490
- await sleep(ctx.config.publishPollIntervalMs);
607
+ await sleepUntilAbort(Math.min(ctx.config.publishPollIntervalMs, Math.max(0, input.deadlineAtMs - nowMs(ctx))), ctx.signal);
491
608
  }
492
- return {
493
- kind: "error",
494
- code: "publish_timeout",
495
- message: `Timed out waiting for the app to go live (last status: ${lastStatus}). Open ${publishUrl(ctx.config, input.applicationId)} to check progress.`,
496
- };
609
+ return { kind: "ok", value: { lastStatus, settled: false } };
497
610
  }
498
611
  /**
499
612
  * Both ways to look at a finished app, in the summary rather than only in the
@@ -501,14 +614,75 @@ async function waitForDeployLive(ctx, input) {
501
614
  * user who is never offered the preview deploys to their whole organization to
502
615
  * see what they built.
503
616
  */
504
- const NEXT_STEPS = "Preview it with preview_app, or publish it with publish_app";
617
+ const NEXT_STEPS = "Inspect it with get_app, or publish it with publish_app";
618
+ /**
619
+ * Which side of a waiting turn owes the next move.
620
+ *
621
+ * A stream hiccup can deliver the plan as chat text instead of a plan card,
622
+ * leaving needs_decision with no structured decision. The turn is waiting on a
623
+ * human either way, so the absence of a plan reads as a question, never as
624
+ * nothing to answer.
625
+ */
626
+ function pendingActionForDecision(decision) {
627
+ if (!decision) {
628
+ return "decide_in_editor";
629
+ }
630
+ switch (decision.kind) {
631
+ case "missing_integrations":
632
+ return "create_integration";
633
+ case "multi_choice":
634
+ return "answer_question";
635
+ case "plan":
636
+ return "approve_plan";
637
+ default: {
638
+ const unhandled = decision;
639
+ return unhandled;
640
+ }
641
+ }
642
+ }
643
+ /**
644
+ * What a waiting turn owes, in the tool result's own closing line.
645
+ *
646
+ * A host without form elicitation (Claude Desktop) has only this line and the
647
+ * server instructions to go on, and it follows the tool's own output more
648
+ * closely. Naming the call before the person - "call edit_app with the answer"
649
+ * - reads as an instruction to answer now, which is how a host ends up
650
+ * approving a plan the user was never shown.
651
+ */
652
+ function decisionSummary(decision) {
653
+ if (!decision) {
654
+ return "Superblocks stopped and is waiting on the user - open the editor to review and decide.";
655
+ }
656
+ if (decision.kind === "missing_integrations") {
657
+ return "Superblocks needs integrations - call create_integration once for every entry in decision.integrations.";
658
+ }
659
+ if (decision.kind === "plan") {
660
+ return "Superblocks proposed a plan and is waiting for the user's decision.";
661
+ }
662
+ if (decision.kind === "multi_choice") {
663
+ return "Superblocks asked the user a question and stopped.";
664
+ }
665
+ const unhandled = decision;
666
+ return unhandled;
667
+ }
668
+ const REPLACE_WEDGED_APP_GUIDANCE = "Superblocks could not start this application's live-edit session. Retrying the build cannot recover this application.";
669
+ const PRESERVE_WEDGED_APP_GUIDANCE = "Superblocks could not open this existing application's live-edit session. Retrying the build cannot recover it.";
670
+ function unavailableReplacementGuidance(applicationId) {
671
+ return `No unused fresh-application replacement is available for ${applicationId}.`;
672
+ }
505
673
  /**
506
674
  * Closing progress line, so the client's last word says whether the app is
507
675
  * committed and openable or still needs something from Claude.
508
676
  */
509
677
  function turnSummary(turn) {
678
+ if (turn.checkpointFailure) {
679
+ return "Superblocks finished building, but its checkpoint could not be saved - open the editor to inspect or recover it";
680
+ }
681
+ if (turn.decision?.kind === "missing_integrations") {
682
+ return decisionSummary(turn.decision);
683
+ }
510
684
  if (turn.generationStatus === "needs_decision") {
511
- return "Superblocks needs a decision — call edit_app with the answer";
685
+ return decisionSummary(turn.decision);
512
686
  }
513
687
  if (turn.generationStatus === "no_changes") {
514
688
  return "The Superblocks job ended without building anything — read its reply";
@@ -517,40 +691,38 @@ function turnSummary(turn) {
517
691
  return "Superblocks is still working, but only the editor can show it — open the edit URL";
518
692
  }
519
693
  if (turn.generationStatus === "live_edit_terminated") {
520
- return turn.retryAction === "retry_import"
521
- ? "The Superblocks live-edit session terminated before anything was built — send the archive again with import_app"
522
- : "The Superblocks live-edit session terminated before anything was built — send the prompt again with edit_app";
694
+ if (turn.retryable === false) {
695
+ return turn.recoveryAction === "start_new_app"
696
+ ? (turn.reply ?? REPLACE_WEDGED_APP_GUIDANCE)
697
+ : (turn.reply ?? PRESERVE_WEDGED_APP_GUIDANCE);
698
+ }
699
+ return "The Superblocks live-edit session terminated before anything was built — send the prompt again with edit_app";
700
+ }
701
+ if (turn.checkpointWarning) {
702
+ return turn.checkpointWarning.message;
523
703
  }
524
704
  if (turn.checkpointCommitId) {
525
- return `Application ready — checkpoint saved. ${NEXT_STEPS}`;
705
+ return `Application ready - checkpoint saved. ${NEXT_STEPS}`;
526
706
  }
527
707
  if (turn.viewStatus === "ready") {
528
708
  return `Application ready. ${NEXT_STEPS}`;
529
709
  }
530
710
  return "Superblocks finished generating; the app may still be building in the editor";
531
711
  }
532
- /**
533
- * Combines local turn activity with durable Redis lines, oldest-first, without
534
- * repeating the same message when both sources saw it.
535
- */
536
- function mergeActivity(live, persisted) {
537
- const seen = new Set();
538
- const merged = [];
539
- for (const line of [...persisted, ...live]) {
540
- if (!line || seen.has(line)) {
541
- continue;
542
- }
543
- seen.add(line);
544
- merged.push(line);
545
- }
546
- return merged;
547
- }
548
712
  async function promptInput(ctx, input) {
549
713
  return {
550
714
  apiKey: ctx.principal.apiKey,
551
715
  applicationId: input.applicationId,
552
716
  ...(input.attachments ? { attachments: input.attachments } : {}),
553
- ...(input.importSource ? { importSource: input.importSource } : {}),
717
+ ...(input.branch ? { branch: input.branch } : {}),
718
+ getLiveEditStatus: ({ liveEditId, signal, }) => ctx.server.getLiveEditStatus({
719
+ apiKey: ctx.principal.apiKey,
720
+ applicationId: input.applicationId,
721
+ branch: input.branch,
722
+ liveEditId,
723
+ signal,
724
+ }),
725
+ idempotencyKey: input.idempotencyKey,
554
726
  keepalive: async () => {
555
727
  await ctx.server.connectLiveEdit({
556
728
  apiKey: ctx.principal.apiKey,
@@ -576,8 +748,13 @@ async function promptInput(ctx, input) {
576
748
  ctx.onProgress?.({ message, type: "progress" });
577
749
  },
578
750
  organizationId: input.organizationId,
751
+ ...(input.pendingStartRequired ? { pendingStartRequired: true } : {}),
752
+ ...(input.pendingStartToken === undefined
753
+ ? {}
754
+ : { pendingStartToken: input.pendingStartToken }),
579
755
  ...(input.planContext ? { planContext: input.planContext } : {}),
580
756
  prompt: input.prompt,
757
+ revision: input.revision,
581
758
  ...(input.responseMetadata
582
759
  ? { responseMetadata: input.responseMetadata }
583
760
  : {}),
@@ -585,6 +762,7 @@ async function promptInput(ctx, input) {
585
762
  apiKey: ctx.principal.apiKey,
586
763
  applicationId: input.applicationId,
587
764
  }),
765
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
588
766
  userId: input.userId,
589
767
  };
590
768
  }
@@ -609,18 +787,6 @@ async function openLiveEdit(ctx, input) {
609
787
  applicationId: input.applicationId,
610
788
  branch: input.branch,
611
789
  });
612
- try {
613
- await ctx.progressCursors?.setLiveEditId({
614
- applicationId: input.applicationId,
615
- liveEditId: session.liveEditId,
616
- organizationId: input.organizationId,
617
- });
618
- }
619
- catch (error) {
620
- console.warn("gateway could not record the live edit for resume", {
621
- message: error instanceof Error ? error.message : String(error),
622
- });
623
- }
624
790
  return session;
625
791
  }
626
792
  /**
@@ -628,12 +794,16 @@ async function openLiveEdit(ctx, input) {
628
794
  * turn running on the held session for `check_app_progress` to follow.
629
795
  */
630
796
  async function startClarkPrompt(ctx, input) {
631
- const started = await ctx.sessionPeer.startPrompt(await promptInput(ctx, input));
632
- if (started.status === "auth_required") {
633
- return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, input.applicationId);
797
+ let prepared;
798
+ try {
799
+ prepared = await promptInput(ctx, input);
800
+ }
801
+ catch (error) {
802
+ throw new PromptNotSentError(error instanceof Error ? error.message : String(error), error);
634
803
  }
635
- if (input.mode === "BUILD") {
636
- ctx.sessionPeer.markBuildMode?.(input.applicationId);
804
+ const started = await ctx.sessionPeer.startPrompt(prepared);
805
+ if (started.status === "auth_required") {
806
+ return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, input.applicationId, input.branch);
637
807
  }
638
808
  return {
639
809
  kind: "ok",
@@ -649,17 +819,27 @@ async function startClarkPrompt(ctx, input) {
649
819
  async function forwardClarkPrompt(ctx, input) {
650
820
  const forward = await ctx.sessionPeer.forwardPrompt(await promptInput(ctx, input));
651
821
  if (forward.status === "auth_required") {
652
- return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, input.applicationId);
822
+ return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, input.applicationId, input.branch);
653
823
  }
654
- if (input.mode === "BUILD") {
655
- ctx.sessionPeer.markBuildMode?.(input.applicationId);
824
+ if (forward.generationStatus === "live_edit_terminated") {
825
+ return {
826
+ kind: "error",
827
+ code: "live_edit_unavailable",
828
+ message: forward.reply.trim() ||
829
+ "The Superblocks live-edit session is unavailable, so nothing was built. Send the prompt again to start it over.",
830
+ ...(forward.recoveryAction === "start_new_app"
831
+ ? { pendingAction: "start_new_app" }
832
+ : forward.retryable === false
833
+ ? {}
834
+ : { pendingAction: "retry_build" }),
835
+ };
656
836
  }
657
837
  if (forward.generationStatus === "timeout") {
658
838
  return {
659
839
  kind: "error",
660
840
  code: "superblocks_turn_timeout",
661
- message: `Superblocks did not finish within the turn budget.${forward.reply ? ` Partial reply: ${forward.reply.slice(0, 500)}` : ""} Continue in the editor at ${editUrl(ctx.config, input.applicationId)}.`,
662
- ...retryIfReplayable(input.attachments),
841
+ message: `Superblocks reported that the turn timed out.${forward.reply ? ` Partial reply: ${forward.reply.slice(0, 500)}` : ""} Continue in the editor at ${editUrl(ctx.config, input.applicationId, input.branch)}.`,
842
+ pendingAction: "retry_build",
663
843
  };
664
844
  }
665
845
  return {
@@ -668,6 +848,12 @@ async function forwardClarkPrompt(ctx, input) {
668
848
  ...(forward.checkpointCommitId
669
849
  ? { checkpointCommitId: forward.checkpointCommitId }
670
850
  : {}),
851
+ ...(forward.checkpointFailure
852
+ ? { checkpointFailure: forward.checkpointFailure }
853
+ : {}),
854
+ ...(forward.checkpointWarning
855
+ ? { checkpointWarning: forward.checkpointWarning }
856
+ : {}),
671
857
  reply: forward.reply,
672
858
  ...(forward.decision ? { decision: forward.decision } : {}),
673
859
  generationStatus: forward.generationStatus,
@@ -685,6 +871,7 @@ async function forwardClarkPrompt(ctx, input) {
685
871
  * `start_app` still returns in the seconds its callers expect.
686
872
  */
687
873
  const DEV_ENV_RETRY_DELAYS_MS = [400, 1200];
874
+ const START_APP_RESPONSE_RESERVE_MS = 5_000;
688
875
  /**
689
876
  * Whether a failed create is worth repeating.
690
877
  *
@@ -708,9 +895,25 @@ function isRetryableCreateFailure(error) {
708
895
  * says the flag read failed rather than blaming their account.
709
896
  */
710
897
  async function createApplicationForPrompt(ctx, args) {
898
+ const { deadlineAtMs: deadline } = args;
899
+ const { deadlineAtMs: _deadlineAtMs, ...createInput } = args;
711
900
  for (let attempt = 0; attempt <= DEV_ENV_RETRY_DELAYS_MS.length; attempt += 1) {
901
+ if (nowMs(ctx) >= deadline) {
902
+ return {
903
+ kind: "error",
904
+ code: "create_application_timeout",
905
+ message: "Superblocks did not finish application creation before the tool-call deadline. No application ID was returned, so retry start_app.",
906
+ };
907
+ }
712
908
  try {
713
- return { kind: "ok", value: await ctx.server.createApplication(args) };
909
+ return {
910
+ kind: "ok",
911
+ value: await ctx.server.createApplication({
912
+ ...createInput,
913
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
914
+ timeoutMs: Math.max(1, deadline - nowMs(ctx)),
915
+ }),
916
+ };
714
917
  }
715
918
  catch (error) {
716
919
  if (!isRetryableCreateFailure(error)) {
@@ -718,7 +921,7 @@ async function createApplicationForPrompt(ctx, args) {
718
921
  }
719
922
  const delayMs = DEV_ENV_RETRY_DELAYS_MS[attempt];
720
923
  if (delayMs !== undefined) {
721
- await sleep(delayMs);
924
+ await sleep(Math.min(delayMs, Math.max(0, deadline - nowMs(ctx))));
722
925
  }
723
926
  }
724
927
  }
@@ -743,13 +946,13 @@ function namingApplication(applicationId, result) {
743
946
  return result.kind === "error" ? { ...result, applicationId } : result;
744
947
  }
745
948
  /**
746
- * Names the application `start_app` or `import_app` created, and says on the
747
- * way out that it is still there.
949
+ * Names the application `start_app` created, and says on the way out that it
950
+ * is still there.
748
951
  *
749
- * These two are the entry points that persist an application before the build
750
- * can start, so their failures are the ones that leave a resource behind. Until
751
- * every caller reads `applicationId` and retries rather than starting over,
752
- * this line is how an operator finds the applications they abandoned.
952
+ * This entry point persists an application before the build can start, so its
953
+ * failures can leave a resource behind. Until every caller reads
954
+ * `applicationId` and retries rather than starting over, this line is how an
955
+ * operator finds applications they abandoned.
753
956
  */
754
957
  function namingCreatedApplication(applicationId, capability, result) {
755
958
  if (result.kind === "error") {
@@ -761,30 +964,13 @@ function namingCreatedApplication(applicationId, capability, result) {
761
964
  }
762
965
  return namingApplication(applicationId, result);
763
966
  }
764
- /**
765
- * The recovery to advertise when the machinery around a turn fails: `edit_app`
766
- * with the same prompt, which is what `retry_build` means.
767
- *
768
- * A regular prompt is retried with `edit_app`. An import prompt carries an
769
- * attachment, so it must be retried with `import_app` to upload the archive
770
- * again and obtain a fresh signed URL.
771
- */
772
- function retryIfReplayable(attachments) {
773
- return attachments && attachments.length > 0
774
- ? { pendingAction: "retry_import" }
775
- : { pendingAction: "retry_build" };
776
- }
777
- /**
778
- * A live edit that would not open. The app itself is intact and Clark never ran,
779
- * so where the prompt can be replayed the caller's way out is to send it again
780
- * rather than start over on a second app.
781
- */
782
- function liveEditUnavailable(error, attachments) {
967
+ /** A live edit that would not open, before Clark saw the prompt. */
968
+ function liveEditUnavailable(error) {
783
969
  return {
784
970
  kind: "error",
785
971
  code: "live_edit_unavailable",
786
972
  message: error instanceof Error ? error.message : String(error),
787
- ...retryIfReplayable(attachments),
973
+ pendingAction: "retry_build",
788
974
  };
789
975
  }
790
976
  /**
@@ -793,8 +979,8 @@ function liveEditUnavailable(error, attachments) {
793
979
  *
794
980
  * Nothing else holds that: no turn was ever started, so without this the gateway
795
981
  * forgets the attempt entirely and every later read answers "nothing in flight"
796
- * - `check_app_progress` reports `unknown` while `get_app` commits and previews
797
- * the untouched starter template as though a build were running (ENG-5808).
982
+ * - `check_app_progress` reports `unknown` while `get_app` reports the untouched
983
+ * starter template as ready (ENG-5808).
798
984
  *
799
985
  * Deliberately not called when the prompt itself failed on an open session. The
800
986
  * prompt may well have reached Superblocks and be running now, and an
@@ -803,19 +989,37 @@ function liveEditUnavailable(error, attachments) {
803
989
  * it can build the app twice.
804
990
  */
805
991
  function rememberFailedStart(ctx, input) {
806
- if (refusedCaller(input.error)) {
992
+ if (refusedCaller(input.error) && !input.pendingStartRequired) {
807
993
  return;
808
994
  }
809
995
  const cause = input.error instanceof Error ? input.error.message : String(input.error);
996
+ const recovery = input.retryable === false
997
+ ? "Gateway cannot safely recover that application by retrying the build."
998
+ : "Nothing is running for that application; send the same instruction again to retry it.";
810
999
  try {
811
- ctx.sessionPeer.recordFailedStart?.({
1000
+ ctx.sessionPeer.recordFailedStart({
812
1001
  applicationId: input.applicationId,
1002
+ ...(input.branch ? { branch: input.branch } : {}),
1003
+ ...(input.idempotencyKey !== undefined
1004
+ ? { idempotencyKey: input.idempotencyKey }
1005
+ : {}),
1006
+ ...(input.generationStatus
1007
+ ? { generationStatus: input.generationStatus }
1008
+ : {}),
1009
+ ...(input.decision ? { decision: input.decision } : {}),
813
1010
  organizationId: input.principal.organizationId,
1011
+ ...(input.pendingStartRequired ? { pendingStartRequired: true } : {}),
1012
+ ...(input.pendingStartToken === undefined
1013
+ ? {}
1014
+ : { pendingStartToken: input.pendingStartToken }),
814
1015
  // Reaches the caller as the dead start's reply on its next poll, so it
815
1016
  // has to stand on its own there rather than lean on this call's error.
816
1017
  reason: input.reason ??
817
- `The Superblocks live-edit session for application ${input.applicationId} never opened, so the prompt never reached Superblocks and nothing was built. Nothing is running for that application; send the same instruction again to retry it. (${cause})`,
1018
+ `The Superblocks live-edit session for application ${input.applicationId} never opened, so the prompt never reached Superblocks and nothing was built. ${recovery} (${cause})`,
1019
+ ...(input.revision === undefined ? {} : { revision: input.revision }),
1020
+ ...(input.recoveryAction ? { recoveryAction: input.recoveryAction } : {}),
818
1021
  ...(input.retryAction ? { retryAction: input.retryAction } : {}),
1022
+ ...(input.retryable === false ? { retryable: false } : {}),
819
1023
  userId: input.principal.userId,
820
1024
  });
821
1025
  }
@@ -847,16 +1051,57 @@ function rememberFailedStart(ctx, input) {
847
1051
  * status for it. A genuinely missing app is the same answer for the same reason.
848
1052
  */
849
1053
  function refusedCaller(error) {
1054
+ if (error instanceof PromptNotSentError) {
1055
+ return refusedCaller(error.cause);
1056
+ }
850
1057
  return (error instanceof ServerHttpError &&
851
1058
  (error.status === 401 || error.status === 403 || error.status === 404));
852
1059
  }
1060
+ function normalizeBranch(branch) {
1061
+ return branch?.trim() || undefined;
1062
+ }
1063
+ /**
1064
+ * Clark's own skillset resolves a missing integration by asking, which works
1065
+ * in the editor and strands a headless turn: the question reaches an MCP host
1066
+ * that cannot answer it, and the integration the project declared never gets
1067
+ * created. Say so on the turn rather than hoping the in-sandbox skill is
1068
+ * loaded.
1069
+ *
1070
+ * Scoped by what the attachment declares, not by the fact that something was
1071
+ * attached. An archive can just as easily hold spreadsheets or PDFs to build
1072
+ * from, and for those "which datasource?" is a fair question with no answer
1073
+ * hiding in the files.
1074
+ */
1075
+ const MISSING_INTEGRATION_INSTRUCTION = `When the attached project declares its own data source connection, that declaration is the answer to which integration it needs. This turn's context lists the org's integrations: reuse one only when it is that same connection - for a database the same host, port, and database, for a REST API the same base URL. A similar name is not a match. When nothing matches, do not ask the caller which one to use, do not ask whether to create one, and do not leave placeholder integration IDs or call the work finished. Call openIntegrationSetup with the exact integration name, plugin ID, and non-secret configuration the project declares. Nobody is watching this session in a browser, so that call answers that the request was recorded for the MCP host: that is success, not failure - the host creates the integration and returns its ID on a follow-up turn. Never ask the caller for a password, API token, or other secret. Continue every part of the work that does not need the live integration, and leave the affected APIs pending until that follow-up supplies the ID. Attached files that declare no connection of their own - data, images, or documents to build from rather than an app to migrate - are outside this: choose or ask about a datasource as you normally would.`;
1076
+ /**
1077
+ * Only attached files can declare a connection, so only those turns carry the
1078
+ * rule. A prompt with nothing attached reaches Clark verbatim.
1079
+ */
1080
+ function promptForAttachedSource(prompt, attachments) {
1081
+ return attachments?.length
1082
+ ? `${prompt}\n\n${MISSING_INTEGRATION_INSTRUCTION}`
1083
+ : prompt;
1084
+ }
1085
+ function artifactAttachments(artifacts) {
1086
+ return artifacts?.length
1087
+ ? artifacts.map((artifact) => ({
1088
+ fileName: artifact.fileName,
1089
+ mediaType: artifact.mediaType,
1090
+ scopeType: "org",
1091
+ storageKey: artifact.storageKey,
1092
+ type: "uploaded",
1093
+ url: `/api/v1/files/content/${encodeURIComponent(artifact.id)}`,
1094
+ }))
1095
+ : undefined;
1096
+ }
853
1097
  /**
854
- * The first prompt on an application that was just created, for both
855
- * `start_app` and `import_app`: connect a live-edit session, hand Clark the
856
- * prompt, and shape the turn it ran.
1098
+ * The first prompt on an application that was just created: connect a live-edit
1099
+ * session, hand Clark the prompt, and shape the turn it ran.
857
1100
  */
858
1101
  async function promptNewApplication(ctx, args) {
859
1102
  const { applicationId, input, principal, prompt } = args;
1103
+ const branch = normalizeBranch(input.branch);
1104
+ const revision = args.pendingStartToken ?? ctx.appState.markTurnStarted();
860
1105
  ctx.onProgress?.({
861
1106
  message: "Connecting live-edit session",
862
1107
  type: "progress",
@@ -866,36 +1111,45 @@ async function promptNewApplication(ctx, args) {
866
1111
  session = await openLiveEdit(ctx, {
867
1112
  apiKey: principal.apiKey,
868
1113
  applicationId,
869
- branch: input.branch,
1114
+ branch,
870
1115
  organizationId: principal.organizationId,
871
1116
  });
872
1117
  }
873
1118
  catch (error) {
874
1119
  rememberFailedStart(ctx, {
875
1120
  applicationId,
1121
+ branch,
876
1122
  error,
1123
+ idempotencyKey: input.idempotencyKey,
1124
+ pendingStartRequired: args.pendingStartToken !== undefined,
1125
+ pendingStartToken: args.pendingStartToken,
877
1126
  principal,
878
- ...(args.attachments?.length ? { retryAction: "retry_import" } : {}),
1127
+ revision,
1128
+ retryAction: "retry_build",
879
1129
  });
880
- return liveEditUnavailable(error, args.attachments);
1130
+ return liveEditUnavailable(error);
881
1131
  }
882
1132
  ctx.onProgress?.({
883
- message: args.importSource
884
- ? "Forwarding import prompt to Superblocks"
885
- : "Forwarding prompt to Superblocks",
1133
+ message: "Forwarding prompt to Superblocks",
886
1134
  type: "progress",
887
1135
  });
888
1136
  const promptArgs = {
889
1137
  applicationId,
890
1138
  ...(args.attachments ? { attachments: args.attachments } : {}),
891
- branch: input.branch,
1139
+ ...(branch ? { branch } : {}),
892
1140
  idempotencyKey: input.idempotencyKey,
893
- ...(args.importSource ? { importSource: args.importSource } : {}),
894
1141
  liveEditId: session.liveEditId,
895
1142
  liveEditUrl: session.liveEditUrl,
896
1143
  mode: input.mode ?? "PLAN",
897
1144
  organizationId: principal.organizationId,
898
- prompt,
1145
+ ...(args.pendingStartToken === undefined
1146
+ ? {}
1147
+ : {
1148
+ pendingStartRequired: true,
1149
+ pendingStartToken: args.pendingStartToken,
1150
+ }),
1151
+ prompt: promptForAttachedSource(prompt, args.attachments),
1152
+ revision,
899
1153
  userId: principal.userId,
900
1154
  };
901
1155
  const named = () => finalAppName(ctx, {
@@ -912,10 +1166,11 @@ async function promptNewApplication(ctx, args) {
912
1166
  }
913
1167
  return {
914
1168
  kind: "ok",
915
- value: await withBrowser(ctx, applicationId, {
1169
+ value: await withBrowser(ctx, {
916
1170
  activity: started.value.activity,
917
1171
  applicationId,
918
- editUrl: editUrl(ctx.config, applicationId),
1172
+ ...(branch ? { branch } : {}),
1173
+ editUrl: editUrl(ctx.config, applicationId, branch),
919
1174
  liveEditId: started.value.liveEditId ?? session.liveEditId,
920
1175
  mode: promptArgs.mode,
921
1176
  name: await named(),
@@ -929,12 +1184,19 @@ async function promptNewApplication(ctx, args) {
929
1184
  }
930
1185
  const result = {
931
1186
  applicationId,
1187
+ ...(branch ? { branch } : {}),
932
1188
  ...(forward.value.checkpointCommitId
933
1189
  ? { checkpointCommitId: forward.value.checkpointCommitId }
934
1190
  : {}),
1191
+ ...(forward.value.checkpointFailure
1192
+ ? { checkpointFailure: forward.value.checkpointFailure }
1193
+ : {}),
1194
+ ...(forward.value.checkpointWarning
1195
+ ? { checkpointWarning: forward.value.checkpointWarning }
1196
+ : {}),
935
1197
  reply: forward.value.reply,
936
1198
  ...(forward.value.decision ? { decision: forward.value.decision } : {}),
937
- editUrl: editUrl(ctx.config, applicationId),
1199
+ editUrl: editUrl(ctx.config, applicationId, branch),
938
1200
  generationStatus: forward.value.generationStatus,
939
1201
  liveEditId: forward.value.liveEditId ?? session.liveEditId,
940
1202
  mode: promptArgs.mode,
@@ -942,30 +1204,117 @@ async function promptNewApplication(ctx, args) {
942
1204
  ...(forward.value.recentRuns && forward.value.recentRuns.length > 0
943
1205
  ? { recentRuns: forward.value.recentRuns }
944
1206
  : {}),
945
- status: buildStatusOf(forward.value.generationStatus),
1207
+ ...(forward.value.checkpointFailure
1208
+ ? { pendingAction: "open_editor" }
1209
+ : {}),
1210
+ status: buildStatusOf(forward.value.generationStatus, forward.value.checkpointFailure),
946
1211
  viewStatus: forward.value.viewStatus,
947
1212
  };
948
1213
  ctx.onProgress?.({
949
1214
  message: turnSummary(forward.value),
950
1215
  type: "completed",
951
1216
  });
952
- return { kind: "ok", value: await withBrowser(ctx, applicationId, result) };
1217
+ return { kind: "ok", value: await withBrowser(ctx, result) };
1218
+ }
1219
+ async function runAcknowledgedStart(ctx, args) {
1220
+ const scope = {
1221
+ applicationId: args.applicationId,
1222
+ ...(args.branch ? { branch: args.branch } : {}),
1223
+ organizationId: args.principal.organizationId,
1224
+ userId: args.principal.userId,
1225
+ };
1226
+ const stopLease = ctx.appState.keepPendingStartAlive(scope, args.pendingStartToken);
1227
+ const failed = (error, reason, generationStatus, retryAction, retryable, recoveryAction) => rememberFailedStart(ctx, {
1228
+ applicationId: args.applicationId,
1229
+ branch: args.branch,
1230
+ error,
1231
+ generationStatus,
1232
+ idempotencyKey: args.idempotencyKey,
1233
+ pendingStartRequired: true,
1234
+ pendingStartToken: args.pendingStartToken,
1235
+ principal: args.principal,
1236
+ reason,
1237
+ revision: args.pendingStartToken,
1238
+ ...(recoveryAction ? { recoveryAction } : {}),
1239
+ ...(retryAction ? { retryAction } : {}),
1240
+ ...(retryable === false ? { retryable } : {}),
1241
+ });
1242
+ try {
1243
+ const result = await args.start();
1244
+ if (result.kind === "error" && result.code === "reinvoke_required") {
1245
+ failed(new Error(result.message), `Authentication completed before Superblocks accepted the prompt for application ${args.applicationId}. Call edit_app with that applicationId and the same prompt to continue.`, "live_edit_terminated", "retry_build");
1246
+ }
1247
+ else if (result.kind === "auth_required" ||
1248
+ (result.kind === "error" && result.code === "needs_browser_driver")) {
1249
+ failed(new Error("authentication required"), `Authentication is required before the prompt can continue. Open ${editUrl(ctx.config, args.applicationId, args.branch)} and authenticate, then call edit_app with that applicationId and the same prompt.`, "live_edit_terminated", "retry_build");
1250
+ }
1251
+ }
1252
+ catch (error) {
1253
+ if (error instanceof PromptNotSentError) {
1254
+ const retryable = error.retryable !== false;
1255
+ failed(error, retryable
1256
+ ? `The prompt was not sent, so nothing was built. Call edit_app for application ${args.applicationId} with the same instruction to retry. (${error.message})`
1257
+ : `The replacement live-edit session also stopped responding, so Gateway cannot safely retry this build. Start a new application instead. (${error.message})`, "live_edit_terminated", retryable ? "retry_build" : undefined, error.retryable, error.recoveryAction);
1258
+ }
1259
+ else {
1260
+ failed(error, `The prompt acknowledgement was lost and the prompt may have reached Superblocks. Do not resend it; open ${editUrl(ctx.config, args.applicationId, args.branch)} to check the existing application. (${error instanceof Error ? error.message : String(error)})`, "detached");
1261
+ }
1262
+ }
1263
+ finally {
1264
+ stopLease();
1265
+ ctx.sessionPeer.clearPendingStart({
1266
+ ...scope,
1267
+ idempotencyKey: args.idempotencyKey,
1268
+ pendingStartToken: args.pendingStartToken,
1269
+ });
1270
+ }
953
1271
  }
954
1272
  export async function startApp(ctx, input) {
1273
+ const createDeadlineAtMs = nowMs(ctx) +
1274
+ Math.max(1, ctx.config.hostCallCeilingMs - START_APP_RESPONSE_RESERVE_MS);
955
1275
  const principalResult = await ensurePrincipal(ctx);
956
1276
  if (principalResult.kind !== "ok") {
957
1277
  return principalResult;
958
1278
  }
959
1279
  const principal = principalResult.value;
1280
+ const branch = normalizeBranch(input.branch);
1281
+ let releaseReplacementClaim;
1282
+ if (input.replacesApplicationId) {
1283
+ const guidance = unavailableReplacementGuidance(input.replacesApplicationId);
1284
+ releaseReplacementClaim = ctx.appState.claimReplacementEntitlement({
1285
+ applicationId: input.replacesApplicationId,
1286
+ organizationId: principal.organizationId,
1287
+ userId: principal.userId,
1288
+ }, guidance);
1289
+ if (!releaseReplacementClaim) {
1290
+ return {
1291
+ applicationId: input.replacesApplicationId,
1292
+ code: "replacement_not_allowed",
1293
+ kind: "error",
1294
+ message: guidance,
1295
+ };
1296
+ }
1297
+ }
960
1298
  const explicitName = input.name?.trim();
961
1299
  const name = explicitName || defaultAppName(input.prompt);
1300
+ const attachments = artifactAttachments(input.artifacts);
962
1301
  ctx.onProgress?.({ message: "Creating application", type: "progress" });
963
- const created = await createApplicationForPrompt(ctx, {
964
- apiKey: principal.apiKey,
965
- name,
966
- organizationId: principal.organizationId,
967
- });
1302
+ let created;
1303
+ try {
1304
+ created = await createApplicationForPrompt(ctx, {
1305
+ apiKey: principal.apiKey,
1306
+ deadlineAtMs: createDeadlineAtMs,
1307
+ name,
1308
+ organizationId: principal.organizationId,
1309
+ templateName: "app-fullstack",
1310
+ });
1311
+ }
1312
+ catch (error) {
1313
+ releaseReplacementClaim?.();
1314
+ throw error;
1315
+ }
968
1316
  if (created.kind !== "ok") {
1317
+ releaseReplacementClaim?.();
969
1318
  return created;
970
1319
  }
971
1320
  const app = created.value;
@@ -973,9 +1322,49 @@ export async function startApp(ctx, input) {
973
1322
  // whether or not the turn ever runs. Waiting for the turn to be accepted left
974
1323
  // the recent-app default pointing at the previous app, and a caller retrying
975
1324
  // after a failure drove that one instead of the app it had just created.
976
- await rememberApplication(ctx, principal, app.id);
1325
+ await rememberApplication(ctx, principal, app.id, branch);
1326
+ if (input.wait === "ack") {
1327
+ const pendingStartToken = ctx.sessionPeer.recordPendingStart({
1328
+ applicationId: app.id,
1329
+ ...(branch ? { branch } : {}),
1330
+ idempotencyKey: input.idempotencyKey,
1331
+ organizationId: principal.organizationId,
1332
+ retryAction: "retry_build",
1333
+ userId: principal.userId,
1334
+ });
1335
+ const { signal: _requestSignal, ...backgroundContext } = ctx;
1336
+ void runAcknowledgedStart(backgroundContext, {
1337
+ applicationId: app.id,
1338
+ branch,
1339
+ idempotencyKey: input.idempotencyKey,
1340
+ pendingStartToken,
1341
+ principal,
1342
+ start: () => promptNewApplication(backgroundContext, {
1343
+ applicationId: app.id,
1344
+ ...(attachments ? { attachments } : {}),
1345
+ explicitName,
1346
+ input,
1347
+ name,
1348
+ pendingStartToken,
1349
+ principal,
1350
+ prompt: input.prompt,
1351
+ }),
1352
+ });
1353
+ return {
1354
+ kind: "ok",
1355
+ value: await withBrowser(ctx, {
1356
+ applicationId: app.id,
1357
+ ...(branch ? { branch } : {}),
1358
+ editUrl: editUrl(ctx.config, app.id, branch),
1359
+ mode: input.mode ?? "PLAN",
1360
+ name,
1361
+ status: "building",
1362
+ }),
1363
+ };
1364
+ }
977
1365
  return namingCreatedApplication(app.id, "start_app", await promptNewApplication(ctx, {
978
1366
  applicationId: app.id,
1367
+ ...(attachments ? { attachments } : {}),
979
1368
  explicitName,
980
1369
  input,
981
1370
  name,
@@ -992,16 +1381,25 @@ async function realPath(candidate) {
992
1381
  return path.resolve(candidate);
993
1382
  }
994
1383
  }
1384
+ async function readHostFile(candidate) {
1385
+ const file = await realPath(candidate);
1386
+ try {
1387
+ const info = await stat(file);
1388
+ if (!info.isFile()) {
1389
+ return undefined;
1390
+ }
1391
+ if (info.size > IMPORT_ZIP_MAX_BYTES) {
1392
+ return { oversizeBytes: info.size };
1393
+ }
1394
+ return { bytes: await readFile(file) };
1395
+ }
1396
+ catch {
1397
+ return undefined;
1398
+ }
1399
+ }
995
1400
  /**
996
- * Reads a candidate path only when it truly sits in one of the configured
997
- * search directories.
998
- *
999
- * The caller is remote and holds nothing but a token, so a path it names must
1000
- * never widen what the gateway will read. Comparison happens after resolving
1001
- * symlinks on both sides: a link planted in a search directory is otherwise
1002
- * enough to walk straight back out of it, and on macOS the directories
1003
- * themselves (`/tmp`) are often links. The resolved path is also what gets
1004
- * read, so re-pointing the link afterwards changes nothing.
1401
+ * Keeps bare-name fallback inside its search directory after resolving
1402
+ * symlinks. Exact absolute paths do not use this fallback boundary.
1005
1403
  */
1006
1404
  async function readWithinSearchDirs(candidate, searchDirs) {
1007
1405
  const file = await realPath(candidate);
@@ -1011,28 +1409,14 @@ async function readWithinSearchDirs(candidate, searchDirs) {
1011
1409
  if (file !== root && !file.startsWith(root + path.sep)) {
1012
1410
  continue;
1013
1411
  }
1014
- try {
1015
- const info = await stat(file);
1016
- if (!info.isFile()) {
1017
- return undefined;
1018
- }
1019
- // Sizing from the file rather than from bytes already read keeps a caller
1020
- // from spending the gateway's heap on a file it merely named.
1021
- if (info.size > IMPORT_ZIP_MAX_BYTES) {
1022
- return { oversizeBytes: info.size };
1023
- }
1024
- return { bytes: await readFile(file) };
1025
- }
1026
- catch {
1027
- return undefined;
1028
- }
1412
+ return readHostFile(file);
1029
1413
  }
1030
1414
  return undefined;
1031
1415
  }
1032
1416
  /**
1033
- * Reads the archive by name from one of the configured search directories.
1417
+ * Reads the file by name from one of the configured search directories.
1034
1418
  *
1035
- * Only the file name of `zipPath` is used. The directory part is thrown away
1419
+ * Only the file name of `filePath` is used. The directory part is thrown away
1036
1420
  * before joining, so neither a sandbox path the model quotes nor a `../` in it
1037
1421
  * can reach a file the operator did not put in one of these folders.
1038
1422
  */
@@ -1045,51 +1429,60 @@ async function readFromSearchDirs(fileName, searchDirs) {
1045
1429
  }
1046
1430
  return undefined;
1047
1431
  }
1432
+ function isLikelyModelSandboxPath(filePath) {
1433
+ return (/^\/tmp\/claude-\d+\//.test(filePath) ||
1434
+ filePath.includes("/-home-claude/") ||
1435
+ filePath.startsWith("/mnt/user-data/") ||
1436
+ filePath.startsWith("/root/.claude/uploads/"));
1437
+ }
1048
1438
  function tooLargeError(size) {
1049
1439
  return {
1050
1440
  kind: "error",
1051
1441
  code: "import_zip_too_large",
1052
- message: `Archive is ${size} bytes; the gateway import limit is ${IMPORT_ZIP_MAX_BYTES} bytes (1 MB). Larger archives almost certainly include node_modules or other install artifacts — re-export without those; dependencies are installed on or after import.`,
1442
+ message: `Archive is ${size} bytes; the gateway import limit is ${IMPORT_ZIP_MAX_BYTES} bytes (50 MB), matching Superblocks archive attachments. Larger archives often include node_modules or other install artifacts — re-export without those; dependencies are installed on or after import.`,
1053
1443
  };
1054
1444
  }
1055
1445
  /**
1056
- * Reads the ZIP (or tarball) from a host path or base64 payload and enforces
1057
- * the gateway's 1 MB cap before any Superblocks API call.
1446
+ * Reads the upload from a host path, inline source files, or a base64 payload,
1447
+ * and enforces the gateway's 50 MB cap before any Superblocks API call.
1448
+ *
1449
+ * A path is the only route that carries bytes the model never has to write
1450
+ * out, so it accepts anything attachments support rather than archives alone.
1451
+ * The other two routes are text channels and stay archive-shaped.
1058
1452
  */
1059
1453
  async function resolveImportArchive(input, searchDirs) {
1060
- const hasPath = Boolean(input.zipPath?.trim());
1454
+ const hasFiles = Boolean(input.files?.length);
1455
+ const hasPath = Boolean(input.filePath?.trim());
1061
1456
  const hasBase64 = Boolean(input.zipBase64?.trim());
1062
- if (hasPath === hasBase64) {
1457
+ if (Number(hasFiles) + Number(hasPath) + Number(hasBase64) !== 1) {
1063
1458
  return {
1064
1459
  kind: "error",
1065
1460
  code: "import_zip_required",
1066
- message: "Provide exactly one of zipPath (a path or file name on the gateway host) or zipBase64.",
1461
+ message: "Provide exactly one of files, filePath (a path or file name on the gateway host), or zipBase64.",
1067
1462
  };
1068
1463
  }
1069
- let bytes;
1070
- let fileName;
1071
1464
  if (hasPath) {
1072
- const zipPath = input.zipPath.trim();
1073
- fileName = path.basename(zipPath);
1074
- let read;
1075
- if (path.isAbsolute(zipPath)) {
1076
- // A path the caller can see is often one this host cannot — a chat
1077
- // attachment lives in the model's sandbox — and a path this host can see
1078
- // is only read when it is in a folder the operator opened up.
1079
- read = await readWithinSearchDirs(zipPath, searchDirs);
1080
- }
1081
- read ??= await readFromSearchDirs(fileName, searchDirs);
1082
- if (read && "oversizeBytes" in read) {
1083
- return tooLargeError(read.oversizeBytes);
1084
- }
1085
- if (!read) {
1465
+ return resolveFileByPath(input.filePath.trim(), searchDirs);
1466
+ }
1467
+ let bytes;
1468
+ if (hasFiles) {
1469
+ const binaryFile = input.files.find((file) => !isTextSourceFileName(file.path));
1470
+ if (binaryFile) {
1086
1471
  return {
1087
1472
  kind: "error",
1088
- code: "import_zip_unreadable",
1089
- message: `No file named "${fileName}" that this gateway will read: "${zipPath}" is not in ${searchDirs.join(" or ")}, and those are the only folders it opens archives from. Ask the user to move the archive into one of them, or to say where it is so the operator can allow that folder. Do not encode the file as base64 — a real archive takes minutes to write out and arrives corrupted.`,
1473
+ code: "import_files_invalid",
1474
+ message: `"${binaryFile.path}" is not a text file, and files entries are archived as the text they contain: base64 in there uploads as base64 characters wearing a "${path.extname(binaryFile.path)}" name. Pass filePath with the file's name instead, so the gateway reads its real bytes.`,
1090
1475
  };
1091
1476
  }
1092
- bytes = read.bytes;
1477
+ const archive = archiveSourceFiles(input.files);
1478
+ if (archive.kind === "error") {
1479
+ return {
1480
+ kind: "error",
1481
+ code: "import_files_invalid",
1482
+ message: archive.message,
1483
+ };
1484
+ }
1485
+ bytes = archive.bytes;
1093
1486
  }
1094
1487
  else {
1095
1488
  try {
@@ -1109,171 +1502,149 @@ async function resolveImportArchive(input, searchDirs) {
1109
1502
  message: "zipBase64 decoded to an empty payload.",
1110
1503
  };
1111
1504
  }
1112
- fileName = "import.zip";
1113
1505
  }
1114
1506
  if (bytes.byteLength > IMPORT_ZIP_MAX_BYTES) {
1115
1507
  return tooLargeError(bytes.byteLength);
1116
1508
  }
1117
- const archiveType = detectAttachmentArchiveType({ fileName });
1118
- if (archiveType === null) {
1119
- return {
1120
- kind: "error",
1121
- code: "import_zip_not_archive",
1122
- message: `Expected a .zip, .tgz, or .gz archive; got "${fileName}".`,
1123
- };
1124
- }
1125
1509
  return {
1126
1510
  kind: "ok",
1127
1511
  value: {
1128
1512
  bytes,
1129
- fileName,
1130
- mimeType: mimeTypeForArchiveType(archiveType),
1513
+ fileName: "import.zip",
1514
+ mimeType: mimeTypeForArchiveType("zip"),
1131
1515
  },
1132
1516
  };
1133
1517
  }
1134
- function mimeTypeForArchiveType(archiveType) {
1135
- switch (archiveType) {
1136
- case "gz":
1137
- case "tgz":
1138
- return "application/gzip";
1139
- case "zip":
1140
- return "application/zip";
1518
+ /** Reads a named file from the search dirs and names the type it uploads as. */
1519
+ async function resolveFileByPath(filePath, searchDirs) {
1520
+ let fileName = path.basename(filePath);
1521
+ let read;
1522
+ if (path.isAbsolute(filePath)) {
1523
+ read = await readHostFile(filePath);
1524
+ }
1525
+ read ??= await readFromSearchDirs(fileName, searchDirs);
1526
+ const originalFileName = fileName.replace(/^[0-9a-f]{8}-/i, "");
1527
+ if (!read &&
1528
+ isLikelyModelSandboxPath(filePath) &&
1529
+ originalFileName !== fileName) {
1530
+ read = await readFromSearchDirs(originalFileName, searchDirs);
1531
+ if (read) {
1532
+ fileName = originalFileName;
1533
+ }
1141
1534
  }
1142
- }
1143
- function normalizeImportSource(source) {
1144
- switch (source) {
1145
- case "chatgpt":
1146
- case "claude":
1147
- case "claude-design":
1148
- case "lovable":
1149
- case "replit":
1150
- case "streamlit":
1151
- case "v0":
1152
- case "zip":
1153
- return source;
1154
- default:
1155
- return "zip";
1535
+ if (read && "oversizeBytes" in read) {
1536
+ return tooLargeError(read.oversizeBytes);
1156
1537
  }
1157
- }
1158
- /**
1159
- * Creates a fullstack app, uploads the caller's archive as an app attachment,
1160
- * and hands Clark a migration prompt — the same path as the browser import
1161
- * wizard, without the sessionStorage hop.
1162
- */
1163
- export async function importApp(ctx, input) {
1164
- const resumedApplicationId = input.applicationId?.trim();
1165
- const archiveResult = await resolveImportArchive(input, ctx.config.importSearchDirs);
1166
- if (archiveResult.kind !== "ok") {
1167
- return resumedApplicationId
1168
- ? namingApplication(resumedApplicationId, archiveResult)
1169
- : archiveResult;
1538
+ if (!read) {
1539
+ const sandboxHint = isLikelyModelSandboxPath(filePath) && isTextSourceFileName(fileName)
1540
+ ? " That path appears to be in the model sandbox rather than on this gateway host. If the file was created in chat, pass it through files: [{ path, content }] instead of filePath."
1541
+ : "";
1542
+ return {
1543
+ kind: "error",
1544
+ code: "import_zip_unreadable",
1545
+ message: `The gateway could not read "${filePath}" or find "${fileName}" in its fallback search folders (${searchDirs.join(" or ")}). Check that the host path exists and is readable.${sandboxHint} Do not encode the file as base64 - a real file takes minutes to write out and arrives corrupted.`,
1546
+ };
1170
1547
  }
1171
- const archive = archiveResult.value;
1172
- const principalResult = await ensurePrincipal(ctx);
1173
- if (principalResult.kind !== "ok") {
1174
- return resumedApplicationId
1175
- ? namingApplication(resumedApplicationId, principalResult)
1176
- : principalResult;
1548
+ if (read.bytes.byteLength > IMPORT_ZIP_MAX_BYTES) {
1549
+ return tooLargeError(read.bytes.byteLength);
1177
1550
  }
1178
- const principal = principalResult.value;
1179
- const source = normalizeImportSource(input.source);
1180
- const migrationPrompt = buildImportPrompt({
1181
- extraPrompt: input.prompt,
1182
- source,
1183
- zipFileName: archive.fileName,
1184
- });
1185
- const explicitName = input.name?.trim();
1186
- const name = explicitName || defaultAppName(migrationPrompt);
1187
- let applicationId = resumedApplicationId;
1188
- if (!applicationId) {
1189
- ctx.onProgress?.({
1190
- message: "Creating application for import",
1191
- type: "progress",
1192
- });
1193
- const created = await createApplicationForPrompt(ctx, {
1194
- apiKey: principal.apiKey,
1195
- name,
1196
- organizationId: principal.organizationId,
1197
- templateName: "app-fullstack",
1198
- });
1199
- if (created.kind !== "ok") {
1200
- return created;
1201
- }
1202
- applicationId = created.value.id;
1551
+ const archiveType = detectAttachmentArchiveType({ fileName });
1552
+ if (archiveType !== null) {
1553
+ return {
1554
+ kind: "ok",
1555
+ value: {
1556
+ bytes: read.bytes,
1557
+ fileName,
1558
+ mimeType: mimeTypeForArchiveType(archiveType),
1559
+ },
1560
+ };
1203
1561
  }
1204
- await rememberApplication(ctx, principal, applicationId);
1205
- const result = await migrateArchiveIntoApplication(ctx, {
1206
- applicationId,
1207
- archive,
1208
- explicitName,
1209
- input,
1210
- migrationPrompt,
1211
- name,
1212
- principal,
1213
- source,
1214
- });
1215
- return resumedApplicationId
1216
- ? namingApplication(applicationId, result)
1217
- : namingCreatedApplication(applicationId, "import_app", result);
1562
+ const mimeType = getAttachmentMimeTypeFromFileName(fileName);
1563
+ if (!mimeType || !isSupportedAttachmentUploadType({ fileName, mimeType })) {
1564
+ return {
1565
+ kind: "error",
1566
+ code: "import_file_unsupported",
1567
+ message: `Superblocks attachments cannot carry "${fileName}". Upload an archive (.zip, .tgz, .gz), an image (.png, .jpg, .gif, .webp), a PDF, or a text file such as .log, .csv, .json, or .md.`,
1568
+ };
1569
+ }
1570
+ return { kind: "ok", value: { bytes: read.bytes, fileName, mimeType } };
1218
1571
  }
1219
1572
  /**
1220
- * Uploads the archive onto an application that already exists and hands
1221
- * Superblocks the migration prompt that reads it.
1573
+ * Whether a `files` entry is something a zip built from strings represents
1574
+ * faithfully. Binary content has no text form, so it would be stored as
1575
+ * whatever encoding the model chose rather than as the file itself.
1222
1576
  */
1223
- async function migrateArchiveIntoApplication(ctx, args) {
1224
- const { applicationId, archive, principal } = args;
1577
+ function isTextSourceFileName(sourcePath) {
1578
+ const fileName = path.basename(sourcePath);
1579
+ if (detectAttachmentArchiveType({ fileName }) !== null) {
1580
+ return false;
1581
+ }
1582
+ if (!path.extname(fileName)) {
1583
+ return true;
1584
+ }
1585
+ const mimeType = getAttachmentMimeTypeFromFileName(fileName);
1586
+ // An unknown extension is source code the archive carries as text; a known
1587
+ // non-text type (image, PDF) is the case this rejects.
1588
+ return !mimeType || getTextAttachmentType({ fileName, mimeType }) !== null;
1589
+ }
1590
+ export async function uploadArtifact(ctx, input) {
1591
+ const principalResult = await ensurePrincipal(ctx);
1592
+ if (principalResult.kind !== "ok") {
1593
+ return principalResult;
1594
+ }
1595
+ const archiveResult = await resolveImportArchive(input, ctx.config.importSearchDirs);
1596
+ if (archiveResult.kind !== "ok") {
1597
+ return archiveResult;
1598
+ }
1599
+ const archive = archiveResult.value;
1600
+ const uploadDescription = input.files?.length
1601
+ ? `${input.files.length} source file${input.files.length === 1 ? "" : "s"} (${archive.bytes.byteLength}-byte archive)`
1602
+ : `file (${archive.bytes.byteLength} bytes)`;
1225
1603
  ctx.onProgress?.({
1226
- message: "Uploading archive attachment",
1604
+ message: `Uploading ${uploadDescription}`,
1227
1605
  type: "progress",
1228
1606
  });
1229
- let upload;
1230
1607
  try {
1231
- upload = await ctx.server.uploadAttachment({
1232
- apiKey: principal.apiKey,
1233
- applicationId,
1608
+ const upload = await ctx.server.uploadAttachment({
1609
+ apiKey: principalResult.value.apiKey,
1234
1610
  bytes: archive.bytes,
1235
1611
  fileName: archive.fileName,
1236
1612
  mimeType: archive.mimeType,
1237
1613
  });
1614
+ const summary = `Uploaded ${uploadDescription}.`;
1615
+ ctx.onProgress?.({
1616
+ message: summary,
1617
+ type: "completed",
1618
+ });
1619
+ return {
1620
+ kind: "ok",
1621
+ value: {
1622
+ artifact: {
1623
+ fileName: upload.fileName,
1624
+ id: upload.attachmentId,
1625
+ mediaType: archive.mimeType,
1626
+ storageKey: upload.storageKey,
1627
+ },
1628
+ summary,
1629
+ },
1630
+ };
1238
1631
  }
1239
1632
  catch (error) {
1240
- const message = error instanceof Error ? error.message : String(error);
1241
- rememberFailedStart(ctx, {
1242
- applicationId,
1243
- error,
1244
- principal,
1245
- reason: `The archive for application ${applicationId} could not be uploaded, so the migration never started and nothing was built. Send the same archive again with import_app to retry it. (${message})`,
1246
- retryAction: "retry_import",
1247
- });
1248
1633
  return {
1249
1634
  kind: "error",
1250
- code: "import_upload_failed",
1251
- message,
1252
- pendingAction: "retry_import",
1635
+ code: "artifact_upload_failed",
1636
+ message: error instanceof Error ? error.message : String(error),
1253
1637
  };
1254
1638
  }
1255
- return promptNewApplication(ctx, {
1256
- applicationId,
1257
- attachments: [
1258
- {
1259
- applicationId,
1260
- fileName: upload.fileName,
1261
- mediaType: upload.mimeType ?? archive.mimeType,
1262
- scopeType: "app",
1263
- signedUrl: upload.signedUrl,
1264
- signedUrlExpiresAt: upload.signedUrlExpiresAt,
1265
- storageKey: upload.storageKey,
1266
- type: "uploaded",
1267
- url: upload.contentUrl,
1268
- },
1269
- ],
1270
- explicitName: args.explicitName,
1271
- importSource: args.source,
1272
- input: args.input,
1273
- name: args.name,
1274
- principal,
1275
- prompt: args.migrationPrompt,
1276
- });
1639
+ }
1640
+ function mimeTypeForArchiveType(archiveType) {
1641
+ switch (archiveType) {
1642
+ case "gz":
1643
+ case "tgz":
1644
+ return "application/gzip";
1645
+ case "zip":
1646
+ return "application/zip";
1647
+ }
1277
1648
  }
1278
1649
  /**
1279
1650
  * Machine-readable shape a cancelled wait answers with.
@@ -1299,63 +1670,177 @@ function nowMs(ctx) {
1299
1670
  * deliberately so: a progress notification is a courtesy the host may ignore,
1300
1671
  * and a call that outlives the host's tool-call ceiling is a hung request
1301
1672
  * whether or not notifications were sent along the way. Every wait in this
1302
- * file is clamped to it, and a call that hits the clamp answers with pacing
1303
- * and a cursor instead of holding the line open.
1673
+ * file is clamped to it, and a call that hits the clamp answers with its
1674
+ * current status instead of holding the line open.
1304
1675
  */
1305
1676
  function hostCallBudgetMs(ctx, requestedMs) {
1306
1677
  return Math.max(0, Math.min(requestedMs, ctx.config.hostCallCeilingMs));
1307
1678
  }
1308
- /** Where a caller's cursor for one application lives. */
1309
- function cursorKeyFor(ctx, principal, applicationId) {
1310
- if (!ctx.caller || !ctx.progressCursors) {
1311
- return undefined;
1679
+ /**
1680
+ * How long the next turn must collect activity for before it may answer.
1681
+ *
1682
+ * Every turn gets its own floor rather than a share of one. The floor is what
1683
+ * makes a talkative build answer in whole thoughts, so a floor that decayed
1684
+ * across turns would leave the later ones nothing to block on, and the poll
1685
+ * would spin against the peer - re-collecting the same activity - instead of
1686
+ * waiting. Clamped to what is left of the poll so a turn is never asked to
1687
+ * wait past the deadline it is measured against.
1688
+ */
1689
+ function turnFloorMs(ctx, remainingWaitMs) {
1690
+ return Math.max(0, Math.min(ctx.config.clarkProgressFloorMs, remainingWaitMs));
1691
+ }
1692
+ /**
1693
+ * Whether this poll may wait out another turn, and what ended it if not.
1694
+ *
1695
+ * The deadlines are reported apart because they fail for different reasons:
1696
+ * the host ceiling means the call itself ran out of room, while the poll
1697
+ * budget means only this poll did and the caller may simply ask again.
1698
+ */
1699
+ function pollStop(ctx, input) {
1700
+ if (ctx.signal?.aborted) {
1701
+ return "cancelled";
1702
+ }
1703
+ // The gateway lost the turn record mid-poll - a dropped socket, a superseded
1704
+ // turn - rather than the turn having ended: this same poll already watched it
1705
+ // run. Named apart from `turn_terminal` because the poll answers with what it
1706
+ // saw rather than with "nothing is running", and because a wait that finds no
1707
+ // turn returns at once, so another one would spin rather than wait.
1708
+ if (input.status === "unknown" && input.sawRunningTurn) {
1709
+ return "turn_record_lost";
1710
+ }
1711
+ if (input.status !== "building") {
1712
+ return "turn_terminal";
1713
+ }
1714
+ const currentMs = nowMs(ctx);
1715
+ if (currentMs >= input.hostDeadlineAtMs) {
1716
+ return "host_deadline_reached";
1717
+ }
1718
+ if (currentMs >= input.deadlineAtMs) {
1719
+ return "poll_budget_spent";
1720
+ }
1721
+ // A gateway configured with no floor answers on the first line, so another
1722
+ // turn would return without having waited for anything.
1723
+ return turnFloorMs(ctx, input.deadlineAtMs - currentMs) === 0
1724
+ ? "floor_spent"
1725
+ : undefined;
1726
+ }
1727
+ /**
1728
+ * What a turn update says about the build, for {@link gatewayDebug}.
1729
+ *
1730
+ * Carries no line Superblocks wrote: a trace of a build is a diagnostic, and
1731
+ * the reply and activity text belong to the user who prompted it. Counting
1732
+ * the activity lines answers the question a trace is read for - whether this
1733
+ * turn said anything at all - without repeating what was said.
1734
+ */
1735
+ function turnTrace(update) {
1736
+ const observed = {
1737
+ "turn.activity_line_count": update.activity.length,
1738
+ "turn.elapsed_ms": update.elapsedMs,
1739
+ ...(update.branch ? { "turn.reported_branch": update.branch } : {}),
1740
+ "turn.status": update.status,
1741
+ ...(update.viewStatus ? { "turn.view_status": update.viewStatus } : {}),
1742
+ };
1743
+ if (update.status !== "finished") {
1744
+ return observed;
1312
1745
  }
1313
1746
  return {
1314
- applicationId,
1315
- caller: ctx.caller,
1316
- organizationId: principal.organizationId,
1747
+ ...observed,
1748
+ "turn.checkpoint_failed": Boolean(update.checkpointFailure),
1749
+ ...(update.decision ? { "turn.decision_kind": update.decision.kind } : {}),
1750
+ "turn.generation_status": update.generationStatus,
1317
1751
  };
1318
1752
  }
1319
1753
  /**
1320
- * Reads the durable stream for this caller without consuming it.
1754
+ * The last running update this poll saw, with `elapsedMs` advanced to now.
1321
1755
  *
1322
- * Returns nothing when resume is not wired (no store, or no live edit known
1323
- * for this application yet), which is what keeps one-shot callers and tests
1324
- * of the live path on exactly the behavior they had.
1756
+ * Used when the turn record goes missing mid-poll. Nothing new was heard, so
1757
+ * the update is reused as-is, but the turn has kept running since it was
1758
+ * observed. Advancing `elapsedMs` keeps {@link recordTurnSignals} measuring the
1759
+ * turn's real age instead of resetting its stall hint.
1325
1760
  */
1326
- async function readSnapshot(ctx, input) {
1327
- const store = ctx.events;
1328
- if (!store || !ctx.progressCursors) {
1329
- return undefined;
1330
- }
1331
- const liveEditId = await ctx.progressCursors.getLiveEditId({
1332
- applicationId: input.applicationId,
1333
- organizationId: input.organizationId,
1334
- });
1335
- if (!liveEditId) {
1336
- return undefined;
1337
- }
1761
+ function carriedForward(ctx, running) {
1762
+ return {
1763
+ ...running.update,
1764
+ elapsedMs: running.update.elapsedMs + Math.max(0, nowMs(ctx) - running.observedAtMs),
1765
+ };
1766
+ }
1767
+ /**
1768
+ * Collects turn updates until the build becomes terminal or this host call
1769
+ * reaches its deadline. Each wait receives only the remaining floor and
1770
+ * ceiling, so an activity wake-up cannot restart either budget.
1771
+ */
1772
+ async function awaitTurns(ctx, requestedBranch, principal, applicationId, hostDeadlineAtMs) {
1773
+ const startedAtMs = nowMs(ctx);
1774
+ const deadlineAtMs = Math.min(hostDeadlineAtMs, startedAtMs + Math.max(0, ctx.config.clarkProgressWaitMs));
1775
+ const waitBudgetMs = Math.max(0, deadlineAtMs - startedAtMs);
1776
+ const activity = [];
1338
1777
  const scope = {
1339
- applicationId: input.applicationId,
1340
- liveEditId,
1341
- organizationId: input.organizationId,
1778
+ "application.id": applicationId,
1779
+ ...(requestedBranch ? { "turn.requested_branch": requestedBranch } : {}),
1342
1780
  };
1343
- // A cursor a caller handed back gets the same validation a stored one does.
1344
- // Anything unreadable falls back to that caller's own stored position: a
1345
- // position this contract cannot vouch for would otherwise skip the caller
1346
- // past unread events, and a corrupt one compares false against every
1347
- // sequence and hides the whole stream for as long as the caller keeps
1348
- // handing it back.
1349
- const supplied = input.overrideCursor === undefined
1350
- ? undefined
1351
- : parseEventCursor(input.overrideCursor);
1352
- return await store.snapshot({
1781
+ gatewayDebug("progress poll started", {
1353
1782
  ...scope,
1354
- cursor: supplied ?? input.cursor?.cursor,
1355
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1783
+ "poll.host_budget_ms": Math.max(0, hostDeadlineAtMs - startedAtMs),
1784
+ "poll.turn_floor_ms": turnFloorMs(ctx, waitBudgetMs),
1785
+ "poll.wait_budget_ms": waitBudgetMs,
1356
1786
  });
1787
+ let running;
1788
+ let turnCount = 0;
1789
+ while (true) {
1790
+ const remainingWaitMs = Math.max(0, deadlineAtMs - nowMs(ctx));
1791
+ const update = await ctx.sessionPeer.awaitTurn({
1792
+ applicationId,
1793
+ branch: requestedBranch,
1794
+ organizationId: principal.organizationId,
1795
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
1796
+ userId: principal.userId,
1797
+ floorMs: turnFloorMs(ctx, remainingWaitMs),
1798
+ waitMs: remainingWaitMs,
1799
+ });
1800
+ turnCount += 1;
1801
+ activity.push(...update.activity);
1802
+ if (update.status === "running") {
1803
+ running = { observedAtMs: nowMs(ctx), update };
1804
+ }
1805
+ const status = progressStatusOf(update);
1806
+ gatewayDebug("progress poll turn observed", {
1807
+ ...scope,
1808
+ ...turnTrace(update),
1809
+ "build.status": status,
1810
+ "poll.turn": turnCount,
1811
+ "poll.wait_remaining_ms": Math.max(0, deadlineAtMs - nowMs(ctx)),
1812
+ });
1813
+ const stop = pollStop(ctx, {
1814
+ deadlineAtMs,
1815
+ hostDeadlineAtMs,
1816
+ sawRunningTurn: running !== undefined,
1817
+ status,
1818
+ });
1819
+ if (!stop) {
1820
+ continue;
1821
+ }
1822
+ // What the poll answers with, which is not always the last wait's update.
1823
+ const answer = stop === "turn_record_lost" && running
1824
+ ? carriedForward(ctx, running)
1825
+ : update;
1826
+ gatewayDebug("progress poll finished", {
1827
+ ...scope,
1828
+ "build.status": progressStatusOf(answer),
1829
+ "poll.elapsed_ms": nowMs(ctx) - startedAtMs,
1830
+ "poll.stopped_because": stop,
1831
+ "poll.turn_count": turnCount,
1832
+ });
1833
+ if (stop === "cancelled") {
1834
+ return namingApplication(applicationId, cancelled("check_app_progress"));
1835
+ }
1836
+ return {
1837
+ kind: "ok",
1838
+ value: { ...answer, activity },
1839
+ };
1840
+ }
1357
1841
  }
1358
1842
  export async function checkAppProgress(ctx, input) {
1843
+ const hostDeadlineAtMs = nowMs(ctx) + Math.max(0, ctx.config.hostCallCeilingMs);
1359
1844
  const principalResult = await ensurePrincipal(ctx);
1360
1845
  if (principalResult.kind !== "ok") {
1361
1846
  return principalResult;
@@ -1366,131 +1851,62 @@ export async function checkAppProgress(ctx, input) {
1366
1851
  return resolved;
1367
1852
  }
1368
1853
  const applicationId = resolved.value;
1854
+ const requestedBranch = normalizeBranch(input.branch);
1369
1855
  // Checked before the wait as well as after it. A caller that cancelled
1370
1856
  // while the credential was being resolved should never reach the point of
1371
1857
  // registering a waiter it will not read.
1372
1858
  if (ctx.signal?.aborted) {
1373
1859
  return namingApplication(applicationId, cancelled("check_app_progress"));
1374
1860
  }
1375
- const cursorKey = cursorKeyFor(ctx, principal, applicationId);
1376
- const stored = cursorKey
1377
- ? await ctx.progressCursors?.getCursor(cursorKey)
1378
- : undefined;
1379
- const update = await ctx.sessionPeer.awaitTurn({
1380
- applicationId,
1381
- organizationId: principal.organizationId,
1382
- ...(ctx.signal ? { signal: ctx.signal } : {}),
1383
- userId: principal.userId,
1384
- waitMs: hostCallBudgetMs(ctx, ctx.config.clarkProgressWaitMs),
1385
- });
1386
- if (ctx.signal?.aborted) {
1387
- return namingApplication(applicationId, cancelled("check_app_progress"));
1861
+ const updateResult = await awaitTurns(ctx, requestedBranch, principal, applicationId, hostDeadlineAtMs);
1862
+ if (updateResult.kind !== "ok") {
1863
+ return updateResult;
1388
1864
  }
1389
- const snapshot = await readSnapshot(ctx, {
1390
- applicationId,
1391
- cursor: stored,
1392
- organizationId: principal.organizationId,
1393
- ...(input.cursor ? { overrideCursor: input.cursor } : {}),
1394
- });
1395
- // The local turn is an accelerator, not the record. When this process is
1396
- // not holding the turn (restart, another replica), the persisted stream is
1397
- // all there is. When it is holding a still-running turn, Redis may still
1398
- // carry durable progress from another path that the local collector missed
1399
- // — merge that activity so a quiet local turn does not look hung.
1400
- const streamProgress = snapshot
1401
- ? readPersistedProgress(snapshot, streamLiveness(ctx), ctx.config.clarkSettleMs)
1402
- : undefined;
1403
- const resumeFromStream = update.status === "unknown" ? streamProgress : undefined;
1865
+ const update = updateResult.value;
1404
1866
  const elapsedSeconds = Math.round(update.elapsedMs / 1000);
1405
- const liveActivity = update.status === "running" && update.activity.length === 0
1406
- ? []
1867
+ const saidSomething = update.activity.length > 0;
1868
+ const activity = update.status === "running" && !saidSomething
1869
+ ? [`Superblocks is still generating`]
1407
1870
  : update.activity;
1408
- const mergedWhileRunning = update.status === "running" && streamProgress
1409
- ? mergeActivity(liveActivity, streamProgress.activity)
1410
- : undefined;
1411
- const activity = resumeFromStream
1412
- ? resumeFromStream.activity
1413
- : mergedWhileRunning && mergedWhileRunning.length > 0
1414
- ? mergedWhileRunning
1415
- : update.status === "running" && update.activity.length === 0
1416
- ? [`Superblocks is still generating (${elapsedSeconds}s)`]
1417
- : liveActivity;
1418
- // Live lines were already shown on earlier polls for this turn. When Redis
1419
- // actually contributed durable lines, only stream those that are new relative
1420
- // to live so the same progress is not re-emitted. An empty/lagging store must
1421
- // still notify the live activity — otherwise a quiet Redis snapshot silences
1422
- // the turn.
1423
- const progressMessages = mergedWhileRunning !== undefined &&
1424
- (streamProgress?.activity.length ?? 0) > 0
1425
- ? mergedWhileRunning.filter((line) => !liveActivity.includes(line))
1426
- : activity;
1427
- for (const message of progressMessages) {
1871
+ for (const message of activity) {
1428
1872
  ctx.onProgress?.({ message, type: "progress" });
1429
1873
  }
1430
- const status = resumeFromStream
1431
- ? resumeFromStream.status
1432
- : update.status === "finished"
1433
- ? buildStatusOf(update.generationStatus)
1434
- : update.status === "running"
1435
- ? "building"
1436
- : "unknown";
1874
+ const status = progressStatusOf(update);
1437
1875
  const terminal = status !== "building";
1438
- const pacing = await recordPacing(ctx, {
1439
- applicationId,
1876
+ const turnSignals = await recordTurnSignals(ctx, {
1440
1877
  appScope: { applicationId, organizationId: principal.organizationId },
1441
- cursorKey,
1442
1878
  elapsedMs: update.elapsedMs,
1443
- // Only advance the Redis cursor when this poll actually answered from
1444
- // persisted events. Advancing while answering from the live socket alone
1445
- // skips unread durable events after a restart.
1446
- advanceCursor: resumeFromStream !== undefined ||
1447
- (update.status === "running" &&
1448
- (streamProgress?.activity.length ?? 0) > 0),
1449
1879
  // What Superblocks itself said, not what this poll is returning. The
1450
- // heartbeat below is the gateway's own words, added so a quiet poll has
1880
+ // heartbeat above is the gateway's own words, added so a quiet poll has
1451
1881
  // something to show; counting it as news would mean a build can never be
1452
1882
  // detected as quiet, because every quiet poll manufactures a line.
1453
- saidSomething: resumeFromStream
1454
- ? resumeFromStream.activity.length > 0
1455
- : update.activity.length > 0 ||
1456
- (mergedWhileRunning !== undefined && mergedWhileRunning.length > 0),
1457
- snapshot,
1458
- stored,
1883
+ saidSomething,
1459
1884
  terminal,
1460
- // Only the local turn carries a clock. A build read out of the persisted
1461
- // stream is running somewhere else and this process cannot say how long
1462
- // for, so it reports no budget rather than inventing a full one.
1463
- turnRunning: update.status === "running",
1464
1885
  });
1886
+ const branch = update.branch ?? requestedBranch;
1465
1887
  const base = {
1466
- ...pacing.pacing,
1888
+ ...turnSignals,
1467
1889
  activity,
1468
1890
  applicationId,
1469
- editUrl: editUrl(ctx.config, applicationId),
1891
+ ...(branch ? { branch } : {}),
1892
+ editUrl: editUrl(ctx.config, applicationId, branch),
1470
1893
  elapsedSeconds,
1471
- ...(pacing.stallHint ? { stallHint: pacing.stallHint } : {}),
1894
+ ...(update.viewStatus ? { viewStatus: update.viewStatus } : {}),
1472
1895
  ...("recentRuns" in update &&
1473
1896
  Array.isArray(update.recentRuns) &&
1474
1897
  update.recentRuns.length > 0
1475
1898
  ? { recentRuns: update.recentRuns }
1476
1899
  : {}),
1477
1900
  };
1478
- if (resumeFromStream) {
1901
+ if (update.status !== "finished") {
1479
1902
  return {
1480
1903
  kind: "ok",
1481
1904
  value: {
1482
1905
  ...base,
1483
- ...(resumeFromStream.generationStatus
1484
- ? { generationStatus: resumeFromStream.generationStatus }
1485
- : {}),
1486
- ...(resumeFromStream.reply ? { reply: resumeFromStream.reply } : {}),
1487
1906
  status,
1488
1907
  },
1489
1908
  };
1490
1909
  }
1491
- if (update.status !== "finished") {
1492
- return { kind: "ok", value: { ...base, status } };
1493
- }
1494
1910
  ctx.onProgress?.({ message: turnSummary(update), type: "completed" });
1495
1911
  return {
1496
1912
  kind: "ok",
@@ -1499,12 +1915,27 @@ export async function checkAppProgress(ctx, input) {
1499
1915
  ...(update.checkpointCommitId
1500
1916
  ? { checkpointCommitId: update.checkpointCommitId }
1501
1917
  : {}),
1918
+ ...(update.checkpointFailure
1919
+ ? { checkpointFailure: update.checkpointFailure }
1920
+ : {}),
1921
+ ...(update.checkpointWarning
1922
+ ? { checkpointWarning: update.checkpointWarning }
1923
+ : {}),
1502
1924
  reply: update.reply,
1503
1925
  ...(update.decision ? { decision: update.decision } : {}),
1504
1926
  generationStatus: update.generationStatus,
1505
- ...(update.retryAction && emptyTurnStatus(update.generationStatus)
1506
- ? { pendingAction: update.retryAction }
1507
- : {}),
1927
+ ...(update.checkpointFailure
1928
+ ? { pendingAction: "open_editor" }
1929
+ : status === "needs_decision"
1930
+ ? { pendingAction: pendingActionForDecision(update.decision) }
1931
+ : update.retryAction && emptyTurnStatus(update.generationStatus)
1932
+ ? { pendingAction: update.retryAction }
1933
+ : update.retryable === false &&
1934
+ emptyTurnStatus(update.generationStatus)
1935
+ ? update.recoveryAction
1936
+ ? { pendingAction: update.recoveryAction }
1937
+ : {}
1938
+ : {}),
1508
1939
  status,
1509
1940
  viewStatus: update.viewStatus,
1510
1941
  },
@@ -1524,7 +1955,7 @@ export async function checkAppProgress(ctx, input) {
1524
1955
  */
1525
1956
  async function lastActivityWithinTurn(ctx, input) {
1526
1957
  try {
1527
- const observedAtMs = await ctx.progressCursors?.getLastActivityAtMs(input.appScope);
1958
+ const observedAtMs = ctx.progress?.lastActivityAtMs(input.appScope);
1528
1959
  return Math.max(observedAtMs ?? input.turnStartedAtMs, input.turnStartedAtMs);
1529
1960
  }
1530
1961
  catch (error) {
@@ -1544,7 +1975,7 @@ async function lastActivityWithinTurn(ctx, input) {
1544
1975
  */
1545
1976
  async function rememberLastActivity(ctx, input) {
1546
1977
  try {
1547
- await ctx.progressCursors?.setLastActivityAtMs(input);
1978
+ ctx.progress?.recordActivity(input);
1548
1979
  }
1549
1980
  catch (error) {
1550
1981
  console.warn("gateway could not record when the build last spoke", {
@@ -1552,16 +1983,8 @@ async function rememberLastActivity(ctx, input) {
1552
1983
  });
1553
1984
  }
1554
1985
  }
1555
- /**
1556
- * Works out what to tell the caller about coming back, and persists its new
1557
- * position.
1558
- *
1559
- * The cursor is written only here, and only after the response it describes
1560
- * has been assembled: advancing it earlier would lose events for a call that
1561
- * then failed. It advances only this caller's key, so a second reader of the
1562
- * same build is unaffected.
1563
- */
1564
- async function recordPacing(ctx, input) {
1986
+ /** Records what this poll heard and derives a nonterminal stall hint. */
1987
+ async function recordTurnSignals(ctx, input) {
1565
1988
  const quiet = !input.saidSomething && !input.terminal;
1566
1989
  const currentTimeMs = nowMs(ctx);
1567
1990
  const turnStartedAtMs = currentTimeMs - input.elapsedMs;
@@ -1572,124 +1995,70 @@ async function recordPacing(ctx, input) {
1572
1995
  turnStartedAtMs,
1573
1996
  });
1574
1997
  const quietMs = Math.max(0, currentTimeMs - lastActivityAtMs);
1575
- const alreadyHinted = input.stored?.stallHintedAtMs !== undefined;
1998
+ const alreadyHinted = ctx.progress?.stallHintedAtMs(input.appScope) !== undefined;
1576
1999
  const stallHint = quiet && !alreadyHinted && quietMs >= ctx.config.clarkStallHintAfterMs
1577
2000
  ? { quietMs, reason: "long_quiet" }
1578
2001
  : undefined;
1579
- // Only what this poll actually heard, and whether or not this caller has a
1580
- // cursor, so a reader that never had one still measures silence from when
1581
- // the build last spoke. A quiet poll writing its own read back would clobber
1582
- // activity a concurrent reader heard between that read and this write.
1583
2002
  if (input.saidSomething) {
1584
2003
  await rememberLastActivity(ctx, {
1585
2004
  ...input.appScope,
1586
2005
  atMs: currentTimeMs,
1587
2006
  });
1588
2007
  }
1589
- if (input.cursorKey && ctx.progressCursors) {
1590
- const advanceCursor = input.advanceCursor !== false;
1591
- const cursor = advanceCursor
1592
- ? (input.snapshot?.cursor ?? input.stored?.cursor ?? EMPTY_EVENT_CURSOR)
1593
- : (input.stored?.cursor ?? EMPTY_EVENT_CURSOR);
1594
- // Hearing anything closes the quiet spell, so the next silence gets its
1595
- // own hint rather than being suppressed by the previous one.
1596
- const hintedAtMs = stallHint
1597
- ? currentTimeMs
1598
- : quiet
1599
- ? input.stored?.stallHintedAtMs
1600
- : undefined;
1601
- await ctx.progressCursors.setCursor({
1602
- ...input.cursorKey,
1603
- record: {
1604
- cursor,
1605
- ...(hintedAtMs === undefined ? {} : { stallHintedAtMs: hintedAtMs }),
1606
- },
2008
+ // Hearing anything closes the quiet spell, so the next silence gets its own
2009
+ // hint rather than being suppressed by the previous one.
2010
+ if (stallHint) {
2011
+ ctx.progress?.recordStallHint({
2012
+ ...input.appScope,
2013
+ atMs: currentTimeMs,
1607
2014
  });
1608
2015
  }
1609
- return {
1610
- pacing: {
1611
- ...(input.snapshot && input.advanceCursor !== false
1612
- ? { cursor: input.snapshot.cursor }
1613
- : input.stored
1614
- ? { cursor: input.stored.cursor }
1615
- : {}),
1616
- nextPollAfterMs: input.terminal
1617
- ? 0
1618
- : nextPollAfterMs(ctx, input.applicationId),
1619
- ...(input.turnRunning
1620
- ? {
1621
- remainingTurnBudgetMs: Math.max(0, ctx.config.clarkTurnTimeoutMs - input.elapsedMs),
1622
- }
1623
- : {}),
1624
- ...(input.snapshot && input.snapshot.lost.length > 0
1625
- ? { staleStreams: input.snapshot.lost }
1626
- : {}),
1627
- },
1628
- ...(stallHint ? { stallHint } : {}),
1629
- };
1630
- }
1631
- /**
1632
- * Pacing for a caller that is being handed a starting point rather than a
1633
- * batch of activity.
1634
- *
1635
- * Reads the caller's stored cursor and hands it straight back without
1636
- * advancing it. `get_app` reports no activity, so moving the cursor here would
1637
- * mark events as read that nobody has read — the caller's next
1638
- * `check_app_progress` would silently skip them.
1639
- *
1640
- * Carries no `remainingTurnBudgetMs`. `get_app` is not the tool that follows a
1641
- * turn and never has the turn's own clock, so any number it put there would be
1642
- * the full budget regardless of how long the turn had been running - or
1643
- * regardless of whether one was running at all, which is what it used to send
1644
- * alongside a preview build (ENG-5808). `check_app_progress` is where a caller
1645
- * following a turn reads what is left of it.
1646
- */
1647
- async function buildPacing(ctx, input) {
1648
- const stored = ctx.caller
1649
- ? await ctx.progressCursors?.getCursor({
1650
- applicationId: input.applicationId,
1651
- caller: ctx.caller,
1652
- organizationId: input.organizationId,
1653
- })
1654
- : undefined;
1655
- return {
1656
- cursor: stored?.cursor ?? EMPTY_EVENT_CURSOR,
1657
- nextPollAfterMs: input.terminal
1658
- ? 0
1659
- : nextPollAfterMs(ctx, input.applicationId),
1660
- };
1661
- }
1662
- /**
1663
- * How soon the caller should poll again. Once the app is building it runs for
1664
- * minutes, so pace at clarkBuildPollAfterMs (~30s) to stay inside a host's
1665
- * tool-call budget; while it is still planning, keep the shorter progress wait
1666
- * so a plan or question surfaces quickly.
1667
- */
1668
- function nextPollAfterMs(ctx, applicationId) {
1669
- const cadenceMs = ctx.sessionPeer.buildMode?.(applicationId)
1670
- ? ctx.config.clarkBuildPollAfterMs
1671
- : ctx.config.clarkProgressWaitMs;
1672
- return hostCallBudgetMs(ctx, cadenceMs);
2016
+ else if (!quiet) {
2017
+ ctx.progress?.recordStallHint({
2018
+ ...input.appScope,
2019
+ atMs: undefined,
2020
+ });
2021
+ }
2022
+ return stallHint ? { stallHint } : {};
1673
2023
  }
1674
- export async function editApp(ctx, input) {
2024
+ export async function editApp(ctx, input, approvedPlan) {
1675
2025
  const principalResult = await ensurePrincipal(ctx);
1676
2026
  if (principalResult.kind !== "ok") {
1677
2027
  return principalResult;
1678
2028
  }
1679
2029
  const principal = principalResult.value;
1680
- const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
2030
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId, { unambiguous: true });
1681
2031
  if (resolved.kind !== "ok") {
1682
2032
  return resolved;
1683
2033
  }
1684
2034
  const applicationId = resolved.value;
1685
- return namingApplication(applicationId, await promptExistingApplication(ctx, { applicationId, input, principal }));
2035
+ return namingApplication(applicationId, await promptExistingApplication(ctx, {
2036
+ applicationId,
2037
+ approvedPlan,
2038
+ input,
2039
+ principal,
2040
+ }));
2041
+ }
2042
+ function stalePlanApproval(applicationId) {
2043
+ return {
2044
+ applicationId,
2045
+ kind: "error",
2046
+ code: "plan_approval_stale",
2047
+ message: "The plan changed after it was approved. Show the current plan to the user before building it.",
2048
+ };
2049
+ }
2050
+ function planApprovalChanged(approvedPlan, pendingPlan) {
2051
+ return approvedPlan !== undefined && pendingPlan !== approvedPlan;
1686
2052
  }
1687
2053
  async function promptExistingApplication(ctx, args) {
1688
- const { applicationId, input, principal } = args;
2054
+ const { applicationId, approvedPlan, input, principal } = args;
2055
+ const branch = currentTurnBranch(ctx, principal, applicationId, input.branch);
2056
+ const answeringQuestion = pendingQuestion(ctx, applicationId, branch);
1689
2057
  // Approve reuses the pending plan as the prompt. Every other path needs the
1690
- // caller's words — an empty or missing prompt would otherwise forward
1691
- // undefined into Clark.
1692
- if (input.planAction !== "approve" &&
2058
+ // caller's words - an empty or missing prompt would otherwise forward
2059
+ // undefined into Clark. Answering a question is not a plan approval, even if
2060
+ // the caller sent planAction "approve".
2061
+ if ((input.planAction !== "approve" || answeringQuestion) &&
1693
2062
  !(typeof input.prompt === "string" && input.prompt.trim().length > 0)) {
1694
2063
  return {
1695
2064
  kind: "error",
@@ -1697,43 +2066,158 @@ async function promptExistingApplication(ctx, args) {
1697
2066
  message: 'edit_app needs a prompt, unless planAction is "approve" (Build it) for a pending plan.',
1698
2067
  };
1699
2068
  }
2069
+ const pendingBeforeOpen = ctx.appState.pendingPlan(applicationId, branch);
2070
+ if (planApprovalChanged(approvedPlan, pendingBeforeOpen)) {
2071
+ return stalePlanApproval(applicationId);
2072
+ }
2073
+ const approvingUncapturedPlan = args.approvingUncapturedPlan === true ||
2074
+ (input.planAction === "approve" &&
2075
+ !pendingBeforeOpen &&
2076
+ !answeringQuestion &&
2077
+ awaitingDecision(ctx, {
2078
+ applicationId,
2079
+ branch,
2080
+ organizationId: principal.organizationId,
2081
+ userId: principal.userId,
2082
+ }));
2083
+ if (input.planAction === "approve" &&
2084
+ !pendingBeforeOpen &&
2085
+ !approvingUncapturedPlan &&
2086
+ !answeringQuestion) {
2087
+ return {
2088
+ kind: "error",
2089
+ code: "plan_required",
2090
+ message: "No pending Superblocks plan to approve for this application. Start or edit in Plan mode first, then approve after needs_decision.",
2091
+ };
2092
+ }
2093
+ if (input.wait === "ack" && args.pendingStartToken === undefined) {
2094
+ const mode = editTurnMode(input, answeringQuestion);
2095
+ const pendingStartToken = ctx.sessionPeer.recordPendingStart({
2096
+ applicationId,
2097
+ ...(branch ? { branch } : {}),
2098
+ idempotencyKey: input.idempotencyKey,
2099
+ organizationId: principal.organizationId,
2100
+ retryAction: "retry_build",
2101
+ userId: principal.userId,
2102
+ });
2103
+ const { signal: _requestSignal, ...backgroundContext } = ctx;
2104
+ void runAcknowledgedStart(backgroundContext, {
2105
+ applicationId,
2106
+ branch,
2107
+ idempotencyKey: input.idempotencyKey,
2108
+ pendingStartToken,
2109
+ principal,
2110
+ start: () => promptExistingApplication(backgroundContext, {
2111
+ ...args,
2112
+ ...(approvingUncapturedPlan ? { approvingUncapturedPlan } : {}),
2113
+ input: { ...input, ...(branch ? { branch } : {}) },
2114
+ pendingStartToken,
2115
+ }),
2116
+ });
2117
+ if (planApprovalChanged(approvedPlan, ctx.appState.pendingPlan(applicationId, branch))) {
2118
+ return stalePlanApproval(applicationId);
2119
+ }
2120
+ rememberApplication(ctx, principal, applicationId, branch);
2121
+ return {
2122
+ kind: "ok",
2123
+ value: await withBrowser(ctx, {
2124
+ applicationId,
2125
+ ...(branch ? { branch } : {}),
2126
+ editUrl: editUrl(ctx.config, applicationId, branch),
2127
+ mode,
2128
+ status: "building",
2129
+ }),
2130
+ };
2131
+ }
2132
+ const revision = args.pendingStartToken ?? ctx.appState.markTurnStarted();
1700
2133
  ctx.onProgress?.({ message: "Ensuring live-edit session", type: "progress" });
1701
2134
  let session;
1702
2135
  try {
1703
2136
  session = await openLiveEdit(ctx, {
1704
2137
  apiKey: principal.apiKey,
1705
2138
  applicationId,
1706
- branch: input.branch,
2139
+ branch,
1707
2140
  organizationId: principal.organizationId,
1708
2141
  });
1709
2142
  }
1710
2143
  catch (error) {
1711
- rememberFailedStart(ctx, { applicationId, error, principal });
2144
+ const pendingDecision = ctx.appState.pendingDecision(applicationId, branch);
2145
+ rememberFailedStart(ctx, {
2146
+ applicationId,
2147
+ branch,
2148
+ error,
2149
+ ...(pendingDecision ? { decision: pendingDecision.decision } : {}),
2150
+ ...(pendingDecision || approvingUncapturedPlan
2151
+ ? { generationStatus: "needs_decision" }
2152
+ : {}),
2153
+ idempotencyKey: input.idempotencyKey,
2154
+ ...(args.pendingStartToken === undefined || refusedCaller(error)
2155
+ ? {}
2156
+ : {
2157
+ pendingStartRequired: true,
2158
+ pendingStartToken: args.pendingStartToken,
2159
+ }),
2160
+ principal,
2161
+ revision,
2162
+ retryAction: "retry_build",
2163
+ });
1712
2164
  return liveEditUnavailable(error);
1713
2165
  }
1714
2166
  ctx.onProgress?.({
1715
2167
  message: "Forwarding prompt to Superblocks",
1716
2168
  type: "progress",
1717
2169
  });
1718
- const pending = ctx.sessionPeer.pendingPlan?.(applicationId);
2170
+ const pending = ctx.appState.pendingPlan(applicationId, branch);
2171
+ if (planApprovalChanged(approvedPlan, pending)) {
2172
+ const stale = stalePlanApproval(applicationId);
2173
+ if (args.pendingStartToken !== undefined) {
2174
+ const currentDecision = ctx.appState.pendingDecision(applicationId, branch);
2175
+ rememberFailedStart(ctx, {
2176
+ applicationId,
2177
+ branch,
2178
+ ...(currentDecision ? { decision: currentDecision.decision } : {}),
2179
+ error: new Error(stale.message),
2180
+ generationStatus: "needs_decision",
2181
+ idempotencyKey: input.idempotencyKey,
2182
+ pendingStartRequired: true,
2183
+ pendingStartToken: args.pendingStartToken,
2184
+ principal,
2185
+ reason: stale.message,
2186
+ revision,
2187
+ });
2188
+ }
2189
+ return stale;
2190
+ }
1719
2191
  // Approve / refine a pending plan the way the editor's "Build it" / chat
1720
- // refine path does. A bare prompt while a plan is pending is refine feedback.
1721
- const effectiveAction = input.planAction ?? (pending ? "refine" : undefined);
1722
- // Clark's headless default is PLAN, so a follow-up prompt after a BUILD turn
1723
- // used to propose a plan instead of continuing the build. Preserve BUILD
1724
- // until the caller explicitly asks to plan, regardless of how BUILD began.
1725
- const defaultMode = ctx.sessionPeer.buildMode?.(applicationId)
1726
- ? "BUILD"
1727
- : "PLAN";
2192
+ // refine path does. A bare prompt while a plan is pending is refine
2193
+ // feedback, unless the caller passed BUILD to skip that plan.
2194
+ const effectiveAction = answeringQuestion
2195
+ ? undefined
2196
+ : (input.planAction ??
2197
+ (pending && input.mode !== "BUILD" ? "refine" : undefined));
2198
+ const attachments = artifactAttachments(input.artifacts);
2199
+ // Every edit starts in Plan mode, exactly like start_app:
2200
+ // writing code is something the caller opts into with mode "BUILD", never a
2201
+ // mode the app latched onto from an earlier build. Approving a pending plan
2202
+ // still forces BUILD below, which is what carries a plan into code.
2203
+ // Answering a question stays in Plan even if the caller passed BUILD.
1728
2204
  let promptArgs = {
1729
2205
  applicationId,
1730
- branch: input.branch,
2206
+ ...(attachments ? { attachments } : {}),
2207
+ branch,
1731
2208
  idempotencyKey: input.idempotencyKey,
1732
2209
  liveEditId: session.liveEditId,
1733
2210
  liveEditUrl: session.liveEditUrl,
1734
- mode: input.mode ?? defaultMode,
2211
+ mode: editTurnMode(input, answeringQuestion),
1735
2212
  organizationId: principal.organizationId,
1736
- prompt: input.prompt ?? "",
2213
+ ...(args.pendingStartToken === undefined
2214
+ ? {}
2215
+ : {
2216
+ pendingStartRequired: true,
2217
+ pendingStartToken: args.pendingStartToken,
2218
+ }),
2219
+ prompt: promptForAttachedSource(input.prompt ?? "", attachments),
2220
+ revision,
1737
2221
  userId: principal.userId,
1738
2222
  };
1739
2223
  if (effectiveAction === "approve") {
@@ -1747,20 +2231,27 @@ async function promptExistingApplication(ctx, args) {
1747
2231
  ...(pending.title ? { title: pending.title } : {}),
1748
2232
  },
1749
2233
  prompt: pending.plan,
1750
- responseMetadata: {
1751
- approved: true,
1752
- ...(pending.messageId
1753
- ? { responseToMessageId: pending.messageId }
1754
- : {}),
1755
- type: "plan_response",
1756
- },
2234
+ // Only when there is a card to answer: `responseToMessageId` is what
2235
+ // the ai-service resolves the plan by, so a `plan_response` without
2236
+ // one answers nothing.
2237
+ ...(pending.messageId
2238
+ ? {
2239
+ responseMetadata: {
2240
+ approved: true,
2241
+ responseToMessageId: pending.messageId,
2242
+ type: "plan_response",
2243
+ },
2244
+ }
2245
+ : {}),
1757
2246
  };
1758
2247
  }
1759
- else if (awaitingDecision(ctx, {
1760
- applicationId,
1761
- organizationId: principal.organizationId,
1762
- userId: principal.userId,
1763
- })) {
2248
+ else if (approvingUncapturedPlan ||
2249
+ awaitingDecision(ctx, {
2250
+ applicationId,
2251
+ branch,
2252
+ organizationId: principal.organizationId,
2253
+ userId: principal.userId,
2254
+ })) {
1764
2255
  // Clark is waiting on the user but streamed its plan as chat text, so no
1765
2256
  // plan card was captured. "Build it" still means build: forward a build
1766
2257
  // turn the way the editor's Build-it does. Clark still holds the plan in
@@ -1771,7 +2262,9 @@ async function promptExistingApplication(ctx, args) {
1771
2262
  planContext: { approved: true },
1772
2263
  prompt: input.prompt?.trim() ||
1773
2264
  "The plan is approved. Build it as described.",
1774
- responseMetadata: { approved: true, type: "plan_response" },
2265
+ // No `responseMetadata`: there is no plan card to answer. The approval
2266
+ // travels in `planContext`, which is what the ai-service reads to gate
2267
+ // its write tools.
1775
2268
  };
1776
2269
  }
1777
2270
  else {
@@ -1791,13 +2284,15 @@ async function promptExistingApplication(ctx, args) {
1791
2284
  plan: pending.plan,
1792
2285
  ...(pending.title ? { title: pending.title } : {}),
1793
2286
  },
1794
- responseMetadata: {
1795
- approved: false,
1796
- ...(pending.messageId
1797
- ? { responseToMessageId: pending.messageId }
1798
- : {}),
1799
- type: "plan_response",
1800
- },
2287
+ ...(pending.messageId
2288
+ ? {
2289
+ responseMetadata: {
2290
+ approved: false,
2291
+ responseToMessageId: pending.messageId,
2292
+ type: "plan_response",
2293
+ },
2294
+ }
2295
+ : {}),
1801
2296
  };
1802
2297
  }
1803
2298
  if (input.wait === "ack") {
@@ -1805,13 +2300,14 @@ async function promptExistingApplication(ctx, args) {
1805
2300
  if (started.kind !== "ok") {
1806
2301
  return started;
1807
2302
  }
1808
- await rememberApplication(ctx, principal, applicationId);
2303
+ await rememberApplication(ctx, principal, applicationId, branch);
1809
2304
  return {
1810
2305
  kind: "ok",
1811
- value: await withBrowser(ctx, applicationId, {
2306
+ value: await withBrowser(ctx, {
1812
2307
  activity: started.value.activity,
1813
2308
  applicationId,
1814
- editUrl: editUrl(ctx.config, applicationId),
2309
+ ...(branch ? { branch } : {}),
2310
+ editUrl: editUrl(ctx.config, applicationId, branch),
1815
2311
  mode: promptArgs.mode,
1816
2312
  status: "building",
1817
2313
  }),
@@ -1821,21 +2317,31 @@ async function promptExistingApplication(ctx, args) {
1821
2317
  if (forward.kind !== "ok") {
1822
2318
  return forward;
1823
2319
  }
1824
- await rememberApplication(ctx, principal, applicationId);
2320
+ await rememberApplication(ctx, principal, applicationId, branch);
1825
2321
  const result = {
1826
2322
  applicationId,
2323
+ ...(branch ? { branch } : {}),
1827
2324
  ...(forward.value.checkpointCommitId
1828
2325
  ? { checkpointCommitId: forward.value.checkpointCommitId }
1829
2326
  : {}),
2327
+ ...(forward.value.checkpointFailure
2328
+ ? { checkpointFailure: forward.value.checkpointFailure }
2329
+ : {}),
2330
+ ...(forward.value.checkpointWarning
2331
+ ? { checkpointWarning: forward.value.checkpointWarning }
2332
+ : {}),
1830
2333
  reply: forward.value.reply,
1831
2334
  ...(forward.value.decision ? { decision: forward.value.decision } : {}),
1832
- editUrl: editUrl(ctx.config, applicationId),
2335
+ editUrl: editUrl(ctx.config, applicationId, branch),
1833
2336
  generationStatus: forward.value.generationStatus,
1834
2337
  mode: promptArgs.mode,
1835
2338
  ...(forward.value.recentRuns && forward.value.recentRuns.length > 0
1836
2339
  ? { recentRuns: forward.value.recentRuns }
1837
2340
  : {}),
1838
- status: buildStatusOf(forward.value.generationStatus),
2341
+ ...(forward.value.checkpointFailure
2342
+ ? { pendingAction: "open_editor" }
2343
+ : {}),
2344
+ status: buildStatusOf(forward.value.generationStatus, forward.value.checkpointFailure),
1839
2345
  viewStatus: forward.value.viewStatus,
1840
2346
  };
1841
2347
  ctx.onProgress?.({
@@ -1844,7 +2350,7 @@ async function promptExistingApplication(ctx, args) {
1844
2350
  });
1845
2351
  return {
1846
2352
  kind: "ok",
1847
- value: await withBrowser(ctx, applicationId, result),
2353
+ value: await withBrowser(ctx, result),
1848
2354
  };
1849
2355
  }
1850
2356
  /**
@@ -1864,7 +2370,7 @@ export async function previewApp(ctx, input) {
1864
2370
  return principalResult;
1865
2371
  }
1866
2372
  const principal = principalResult.value;
1867
- const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
2373
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId, { unambiguous: true });
1868
2374
  if (resolved.kind !== "ok") {
1869
2375
  return resolved;
1870
2376
  }
@@ -1885,7 +2391,7 @@ export async function previewApp(ctx, input) {
1885
2391
  catch (error) {
1886
2392
  if (error instanceof ServerHttpError &&
1887
2393
  (error.status === 401 || error.status === 403)) {
1888
- return elicitStepUp(ctx, `stepup:preview:${applicationId}`, applicationId);
2394
+ return elicitStepUp(ctx, `stepup:preview:${applicationId}`, applicationId, input.branch);
1889
2395
  }
1890
2396
  return {
1891
2397
  kind: "error",
@@ -1912,8 +2418,11 @@ export async function previewApp(ctx, input) {
1912
2418
  if (ready.kind !== "ok") {
1913
2419
  return ready;
1914
2420
  }
2421
+ const gatewayChromium = await gatewayChromiumIsPresent(ctx);
2422
+ const includeScreenshot = input.includeScreenshot !== false;
1915
2423
  let jwt;
1916
- if (ctx.config.deployedShellBaseUrl) {
2424
+ if (ctx.config.deployedShellBaseUrl ||
2425
+ (includeScreenshot && ctx.capturePreviewScreenshot && gatewayChromium)) {
1917
2426
  try {
1918
2427
  jwt = await ctx.server.issueScopedToken({
1919
2428
  apiKey: principal.apiKey,
@@ -1937,10 +2446,14 @@ export async function previewApp(ctx, input) {
1937
2446
  publicViewerBaseUrl: ctx.config.publicViewerBaseUrl,
1938
2447
  uiPreviewUrl: url,
1939
2448
  });
2449
+ const captureUrl = buildEmbedShellPreviewUrl({
2450
+ applicationId,
2451
+ deployedShellBaseUrl: ctx.config.deployedShellBaseUrl,
2452
+ uiBaseUrl: ctx.config.uiBaseUrl,
2453
+ });
1940
2454
  let screenshotBase64;
2455
+ let screenshotUnavailableReason;
1941
2456
  let needsBrowserDriver = false;
1942
- const gatewayChromium = await gatewayChromiumIsPresent(ctx);
1943
- const includeScreenshot = input.includeScreenshot !== false;
1944
2457
  // Always capture when the preview is ready and Gateway Chromium is present.
1945
2458
  // urls_only must not wait on chromium.launch() (ENG-5858).
1946
2459
  if (!includeScreenshot) {
@@ -1969,19 +2482,35 @@ export async function previewApp(ctx, input) {
1969
2482
  });
1970
2483
  needsBrowserDriver = true;
1971
2484
  }
1972
- else {
2485
+ else if (!jwt) {
2486
+ screenshotUnavailableReason =
2487
+ "Preview screenshot unavailable because its scoped credential could not be created.";
2488
+ console.error("gateway preview screenshot skipped", {
2489
+ applicationId,
2490
+ reason: "no scoped preview token",
2491
+ });
2492
+ }
2493
+ else if (!isTrustedEmbedOrigin(captureUrl, ctx.config.serverUrl)) {
2494
+ // Capturing anyway would photograph the shell's unauthenticated error page
2495
+ // and report it as the app.
2496
+ screenshotUnavailableReason =
2497
+ "Preview screenshots run only against a local stack or the Superblocks origin this session is signed in to.";
2498
+ console.error("gateway preview screenshot skipped", {
2499
+ applicationId,
2500
+ reason: "shell origin is not the signed-in control plane",
2501
+ url: captureUrl,
2502
+ });
2503
+ }
2504
+ else {
1973
2505
  ctx.onProgress?.({
1974
2506
  message: "Capturing a preview screenshot",
1975
2507
  type: "progress",
1976
2508
  });
1977
2509
  try {
1978
2510
  const shot = await ctx.capturePreviewScreenshot({
1979
- embedToken: principal.apiKey,
1980
- url: buildEmbedShellPreviewUrl({
1981
- applicationId,
1982
- deployedShellBaseUrl: ctx.config.deployedShellBaseUrl,
1983
- uiBaseUrl: ctx.config.uiBaseUrl,
1984
- }),
2511
+ embedToken: jwt,
2512
+ trustedOrigin: ctx.config.serverUrl,
2513
+ url: captureUrl,
1985
2514
  });
1986
2515
  screenshotBase64 = shot?.dataBase64;
1987
2516
  // A capture that soft-fails looks exactly like one that was never asked
@@ -1992,7 +2521,7 @@ export async function previewApp(ctx, input) {
1992
2521
  });
1993
2522
  }
1994
2523
  catch (error) {
1995
- // Screenshot is optional; never fail get_app / preview_app for it.
2524
+ // Screenshot is optional; never fail the commit preview for it.
1996
2525
  console.warn("gateway preview screenshot threw; continuing with URLs", {
1997
2526
  message: error instanceof Error ? error.message : String(error),
1998
2527
  });
@@ -2008,20 +2537,87 @@ export async function previewApp(ctx, input) {
2008
2537
  });
2009
2538
  return {
2010
2539
  kind: "ok",
2011
- value: await withBrowser(ctx, applicationId, {
2540
+ value: await withBrowser(ctx, {
2012
2541
  applicationId,
2542
+ ...(input.branch ? { branch: input.branch } : {}),
2013
2543
  commitId: commit.commitId,
2014
- editUrl: editUrl(ctx.config, applicationId),
2544
+ directoryHash,
2545
+ editUrl: editUrl(ctx.config, applicationId, input.branch),
2015
2546
  ...(name ? { name } : {}),
2016
2547
  ...(needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2017
2548
  openableViaHttps: openable.openableViaHttps,
2018
2549
  previewUrl: openable.previewUrl,
2019
2550
  ...(screenshotBase64 ? { screenshotBase64 } : {}),
2551
+ ...(screenshotUnavailableReason ? { screenshotUnavailableReason } : {}),
2020
2552
  status: ready.value.built ? "ready" : "building",
2021
2553
  viewerUrl: openable.viewerUrl,
2022
2554
  }),
2023
2555
  };
2024
2556
  }
2557
+ /** Read the build backing an existing commit preview without creating work. */
2558
+ export async function getPreviewStatus(ctx, input) {
2559
+ const principalResult = await ensurePrincipal(ctx);
2560
+ if (principalResult.kind !== "ok") {
2561
+ return principalResult;
2562
+ }
2563
+ const { wait, ...preview } = input;
2564
+ const deadline = nowMs(ctx) + hostCallBudgetMs(ctx, ctx.config.publishWaitTimeoutMs);
2565
+ let status = "building";
2566
+ for (;;) {
2567
+ if (ctx.signal?.aborted) {
2568
+ return namingApplication(input.applicationId, cancelled("get_preview_status"));
2569
+ }
2570
+ if (wait && nowMs(ctx) >= deadline) {
2571
+ break;
2572
+ }
2573
+ let progress;
2574
+ try {
2575
+ progress = await ctx.server.getBuildProgress({
2576
+ apiKey: principalResult.value.apiKey,
2577
+ applicationId: input.applicationId,
2578
+ directoryHash: input.directoryHash,
2579
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
2580
+ ...(wait ? { timeoutMs: Math.max(1, deadline - nowMs(ctx)) } : {}),
2581
+ });
2582
+ }
2583
+ catch (error) {
2584
+ if (ctx.signal?.aborted) {
2585
+ return namingApplication(input.applicationId, cancelled("get_preview_status"));
2586
+ }
2587
+ if (wait &&
2588
+ nowMs(ctx) >= deadline &&
2589
+ !(error instanceof ServerHttpError)) {
2590
+ break;
2591
+ }
2592
+ return {
2593
+ kind: "error",
2594
+ code: "preview_build_failed",
2595
+ message: error instanceof Error ? error.message : String(error),
2596
+ };
2597
+ }
2598
+ if (ctx.signal?.aborted) {
2599
+ return namingApplication(input.applicationId, cancelled("get_preview_status"));
2600
+ }
2601
+ status =
2602
+ progress === "success"
2603
+ ? "ready"
2604
+ : progress === "failed" || progress === "timed_out"
2605
+ ? "failed"
2606
+ : "building";
2607
+ if (!wait || status !== "building" || nowMs(ctx) >= deadline) {
2608
+ break;
2609
+ }
2610
+ await sleepUntilAbort(Math.min(ctx.config.publishPollIntervalMs, deadline - nowMs(ctx)), ctx.signal);
2611
+ }
2612
+ return {
2613
+ kind: "ok",
2614
+ value: {
2615
+ ...preview,
2616
+ editUrl: editUrl(ctx.config, input.applicationId, input.branch),
2617
+ status,
2618
+ },
2619
+ };
2620
+ }
2025
2621
  /**
2026
2622
  * The app's display name, or undefined when it cannot be read. A label is
2027
2623
  * never worth failing a preview over, so every failure here is swallowed.
@@ -2041,6 +2637,19 @@ async function applicationName(ctx, principal, applicationId) {
2041
2637
  return undefined;
2042
2638
  }
2043
2639
  }
2640
+ function pendingQuestion(ctx, applicationId, branch) {
2641
+ return (ctx.appState.pendingDecision(applicationId, branch)?.decision.kind ===
2642
+ "multi_choice");
2643
+ }
2644
+ function editTurnMode(input, pendingQuestion) {
2645
+ if (pendingQuestion) {
2646
+ return "PLAN";
2647
+ }
2648
+ if (input.planAction === "approve") {
2649
+ return "BUILD";
2650
+ }
2651
+ return input.mode ?? "PLAN";
2652
+ }
2044
2653
  /**
2045
2654
  * Whether the app's last turn finished waiting on the user. True even when the
2046
2655
  * collector captured no structured plan/question (Clark streamed it as chat
@@ -2048,12 +2657,13 @@ async function applicationName(ctx, principal, applicationId) {
2048
2657
  * same as a plan card instead of dead-ending or previewing the template.
2049
2658
  */
2050
2659
  function awaitingDecision(ctx, scope) {
2051
- const turn = ctx.sessionPeer.turnState?.(scope);
2660
+ const turn = ctx.sessionPeer.turnState(scope);
2052
2661
  return (turn?.phase === "finished" && turn.generationStatus === "needs_decision");
2053
2662
  }
2054
2663
  function emptyTurnStatus(status) {
2055
2664
  switch (status) {
2056
2665
  case "cancelled":
2666
+ case "detached":
2057
2667
  case "live_edit_terminated":
2058
2668
  case "no_changes":
2059
2669
  case "timeout":
@@ -2069,6 +2679,9 @@ function localUnbuiltReason(turn, pending) {
2069
2679
  if (turn?.phase === "running") {
2070
2680
  return { kind: "running" };
2071
2681
  }
2682
+ if (turn?.phase === "finished" && turn.checkpointFailure) {
2683
+ return { failure: turn.checkpointFailure, kind: "checkpoint_failed" };
2684
+ }
2072
2685
  if (pending) {
2073
2686
  return { decision: pending.decision, kind: "awaiting_decision" };
2074
2687
  }
@@ -2092,135 +2705,49 @@ function localUnbuiltReason(turn, pending) {
2092
2705
  return status
2093
2706
  ? {
2094
2707
  kind: "empty_turn",
2095
- retryAction: turn.retryAction ?? "retry_build",
2708
+ ...(turn.retryAction
2709
+ ? { pendingAction: turn.retryAction }
2710
+ : turn.retryable === false
2711
+ ? turn.recoveryAction
2712
+ ? { pendingAction: turn.recoveryAction }
2713
+ : {}
2714
+ : turn.generationStatus === "detached"
2715
+ ? {}
2716
+ : { pendingAction: "retry_build" }),
2717
+ ...(turn.reply ? { reply: turn.reply } : {}),
2096
2718
  status,
2097
2719
  }
2098
2720
  : undefined;
2099
2721
  }
2100
- /**
2101
- * The same question asked of what was persisted, for a caller with no live turn
2102
- * to ask.
2103
- *
2104
- * Only a status that cannot coexist with committed work is read from here. The
2105
- * gateway checkpoints a turn that *completed*, and the stream carries no record
2106
- * of whether that landed, so a `cancelled` in the tail may well have a real app
2107
- * behind it and withholding its preview would hide one. A live edit that died
2108
- * and a turn still waiting on the user both certainly never reached completed.
2109
- *
2110
- * A stream with no terminal state at all is a build still running as far as it
2111
- * knows, and so is one this gateway timed out - a gateway budget is not
2112
- * something the dev server records. Neither is grounds to withhold a preview.
2113
- */
2114
- async function persistedUnbuiltReason(ctx, principal, applicationId) {
2115
- let snapshot;
2116
- try {
2117
- snapshot = await readSnapshot(ctx, {
2118
- applicationId,
2119
- // get_app is not a poll: it reads the tail, which is cursor-independent,
2120
- // and must not move any caller's place in the stream.
2121
- cursor: undefined,
2122
- // Only the tail is read below, and the tail does not depend on this. It
2123
- // keeps the call from dragging back the whole retained stream to answer
2124
- // one question about the end of it.
2125
- limit: 1,
2126
- organizationId: principal.organizationId,
2127
- });
2128
- }
2129
- catch (error) {
2130
- // This is the only I/O on a path that had none. An event store having a
2131
- // bad moment is not a reason for get_app to fail: fall back to what the
2132
- // call answered before there was a stream to ask.
2133
- console.warn("gateway get_app persisted state unreadable", {
2134
- applicationId,
2135
- error: error instanceof Error ? error.message : String(error),
2136
- });
2137
- return undefined;
2138
- }
2139
- if (!snapshot) {
2722
+ async function unbuiltReason(ctx, principal, applicationId, requestedBranch) {
2723
+ const branch = normalizeBranch(requestedBranch);
2724
+ const turn = ctx.sessionPeer.turnState({
2725
+ applicationId,
2726
+ branch,
2727
+ organizationId: principal.organizationId,
2728
+ userId: principal.userId,
2729
+ });
2730
+ const candidate = ctx.appState.pendingDecision(applicationId, branch);
2731
+ const pending = turn && candidate?.branch !== turn.branch ? undefined : candidate;
2732
+ const reason = localUnbuiltReason(turn, pending);
2733
+ if (!reason) {
2140
2734
  return undefined;
2141
2735
  }
2142
- const { generationStatus } = readPersistedProgress(snapshot, streamLiveness(ctx), ctx.config.clarkSettleMs);
2143
- return generationStatus ? persistedTurnEnd(generationStatus) : undefined;
2144
- }
2145
- /**
2146
- * When to stop believing what the stream last said. A turn cannot outlive its
2147
- * own budget, so work silent for longer than that has ended whatever the tail
2148
- * still holds.
2149
- *
2150
- * Shared by both readers so one rule governs every status read out of a stream,
2151
- * even where a caller happens not to use the part it governs: this reader takes
2152
- * only `generationStatus`, which never expires, while `check_app_progress` reads
2153
- * the status the rule applies to.
2154
- */
2155
- function streamLiveness(ctx) {
2156
- return {
2157
- nowMs: nowMs(ctx),
2158
- staleAfterMs: ctx.config.clarkTurnTimeoutMs,
2159
- };
2160
- }
2161
- /**
2162
- * What a terminal status read from the stream says about previewing the app.
2163
- *
2164
- * Exhaustive on purpose: the live reader answers this same question from a
2165
- * `TurnState`, and the two are only guaranteed to agree while every status has
2166
- * been given an answer on both. A new one fails to compile here rather than
2167
- * quietly defaulting to buildable.
2168
- *
2169
- * `no_changes` never appears. Which of `completed` and `no_changes` a turn
2170
- * reached is decided by counting the work signals the collector saw, and the
2171
- * stream carries no such signal - the dev server reports the state, not the
2172
- * files. A no-work turn therefore reads as `completed` here and is previewed.
2173
- *
2174
- * TODO(ENG-5911): close that by persisting the gateway's own account of the
2175
- * turn - the settled status and whether a checkpoint landed - rather than
2176
- * adding another rule over the states the stream already has.
2177
- */
2178
- function persistedTurnEnd(status) {
2179
- switch (status) {
2180
- // TODO(ENG-5912): carry the decision itself. The stream has the plan - it
2181
- // is an assistant `chat_message` with a `messageType` - but nothing reads
2182
- // it back into a `TurnDecision`, so a plan resumed from here renders as a
2183
- // plain question and cannot be approved.
2184
- case "needs_decision":
2185
- return { kind: "awaiting_decision" };
2186
- // The stream never recorded whether the prompt carried an archive, so
2187
- // this path cannot pick retry_build vs retry_import. Omit rather than guess.
2188
- case "live_edit_terminated":
2189
- return { kind: "empty_turn", status };
2190
- // A `cancelled` in the tail may still have a real app behind it: the
2191
- // gateway checkpoints a turn that completed and the stream carries no
2192
- // record of whether that landed, so withholding the preview would hide
2193
- // one. `detached` and `timeout` are the gateway's own verdicts, which the
2194
- // dev server never records, and `completed` is the case this gate exists
2195
- // to let through.
2196
- case "cancelled":
2197
- case "completed":
2198
- case "detached":
2199
- case "no_changes":
2200
- case "timeout":
2201
- return undefined;
2202
- default: {
2203
- const exhaustive = status;
2204
- return exhaustive;
2205
- }
2206
- }
2736
+ const actualBranch = turn ? turn.branch : (pending?.branch ?? branch);
2737
+ return { ...(actualBranch ? { branch: actualBranch } : {}), reason };
2207
2738
  }
2208
- async function unbuiltReason(ctx, principal, applicationId) {
2209
- const turn = ctx.sessionPeer.turnState?.({
2739
+ function currentTurnBranch(ctx, principal, applicationId, requestedBranch) {
2740
+ const branch = normalizeBranch(requestedBranch);
2741
+ const turn = ctx.sessionPeer.turnState({
2210
2742
  applicationId,
2743
+ branch,
2211
2744
  organizationId: principal.organizationId,
2212
2745
  userId: principal.userId,
2213
2746
  });
2214
- const pending = ctx.sessionPeer.pendingDecision?.(applicationId);
2215
- if (turn || pending) {
2216
- return localUnbuiltReason(turn, pending);
2747
+ if (turn) {
2748
+ return turn.branch;
2217
2749
  }
2218
- // The local turn is an accelerator, not the record. With nothing held here -
2219
- // after a restart, on another replica, or once a finished turn was released -
2220
- // the persisted stream is the only account of the turn that is left, and
2221
- // falling through without asking it is how the untouched starter template
2222
- // gets built and shown as a finished app.
2223
- return await persistedUnbuiltReason(ctx, principal, applicationId);
2750
+ return ctx.appState.pendingDecision(applicationId, branch)?.branch ?? branch;
2224
2751
  }
2225
2752
  /**
2226
2753
  * The answer `get_app` owes a caller while the app has nothing worth showing.
@@ -2230,35 +2757,57 @@ async function unbuiltReason(ctx, principal, applicationId) {
2230
2757
  * the untouched template, and the caller shows the blank result as if the app
2231
2758
  * were ready. Undefined once Clark's turn is over and a preview means something.
2232
2759
  */
2233
- async function unbuiltApp(ctx, principal, applicationId) {
2234
- const reason = await unbuiltReason(ctx, principal, applicationId);
2235
- if (!reason) {
2760
+ async function unbuiltApp(ctx, principal, applicationId, requestedBranch) {
2761
+ const branch = normalizeBranch(requestedBranch);
2762
+ const state = await unbuiltReason(ctx, principal, applicationId, branch);
2763
+ if (!state) {
2236
2764
  return undefined;
2237
2765
  }
2766
+ const { reason } = state;
2767
+ const actualBranch = state.branch ?? branch;
2238
2768
  const name = await applicationName(ctx, principal, applicationId);
2239
- await rememberApplication(ctx, principal, applicationId);
2240
- const app = await withBrowser(ctx, applicationId, {
2769
+ await rememberApplication(ctx, principal, applicationId, actualBranch);
2770
+ const app = await withBrowser(ctx, {
2241
2771
  applicationId,
2242
- editUrl: editUrl(ctx.config, applicationId),
2772
+ ...(actualBranch ? { branch: actualBranch } : {}),
2773
+ editUrl: editUrl(ctx.config, applicationId, actualBranch),
2243
2774
  ...(name ? { name } : {}),
2244
2775
  });
2245
2776
  if (reason.kind === "awaiting_decision") {
2777
+ // The remembered plan counts: a plan the caller can show the user beats
2778
+ // sending them to the editor, and it can only be a plan, so reading it
2779
+ // back cannot mislabel a question as one (ENG-5910).
2780
+ const decision = decisionToPutToTheUser(ctx, applicationId, actualBranch, reason.decision);
2246
2781
  return {
2247
2782
  kind: "ok",
2248
2783
  value: {
2249
2784
  ...app,
2250
- ...(reason.decision ? { decision: reason.decision } : {}),
2251
- pendingAction: reason.decision?.kind === "plan" ? "approve_plan" : "answer_question",
2785
+ ...(decision ? { decision } : {}),
2786
+ pendingAction: pendingActionForDecision(decision),
2252
2787
  status: "needs_decision",
2253
2788
  },
2254
2789
  };
2255
2790
  }
2791
+ if (reason.kind === "checkpoint_failed") {
2792
+ return {
2793
+ kind: "ok",
2794
+ value: {
2795
+ ...app,
2796
+ checkpointFailure: reason.failure,
2797
+ pendingAction: "open_editor",
2798
+ status: "checkpoint_failed",
2799
+ },
2800
+ };
2801
+ }
2256
2802
  if (reason.kind === "empty_turn") {
2257
2803
  return {
2258
2804
  kind: "ok",
2259
2805
  value: {
2260
2806
  ...app,
2261
- ...(reason.retryAction ? { pendingAction: reason.retryAction } : {}),
2807
+ ...(reason.pendingAction
2808
+ ? { pendingAction: reason.pendingAction }
2809
+ : {}),
2810
+ ...(reason.reply ? { reply: reason.reply } : {}),
2262
2811
  status: reason.status,
2263
2812
  },
2264
2813
  };
@@ -2266,11 +2815,6 @@ async function unbuiltApp(ctx, principal, applicationId) {
2266
2815
  return {
2267
2816
  kind: "ok",
2268
2817
  value: {
2269
- ...(await buildPacing(ctx, {
2270
- applicationId,
2271
- organizationId: principal.organizationId,
2272
- terminal: false,
2273
- })),
2274
2818
  ...app,
2275
2819
  pendingAction: "wait_for_build",
2276
2820
  status: "building",
@@ -2278,8 +2822,86 @@ async function unbuiltApp(ctx, principal, applicationId) {
2278
2822
  };
2279
2823
  }
2280
2824
  /**
2281
- * Lovable-style lookup: editor URL always, preview URLs when a build can be
2282
- * ensured (default). Captures a screenshot whenever the preview is ready.
2825
+ * The plan or question this app is waiting on, in full.
2826
+ *
2827
+ * `unbuiltReason` answers whether the turn is waiting; it does not always
2828
+ * carry what it is waiting on, because Superblocks can stream a plan as chat
2829
+ * text and leave the collector no plan card to capture. The plan itself is
2830
+ * remembered either way, so the form is built from that rather than from
2831
+ * nothing.
2832
+ */
2833
+ function decisionToPutToTheUser(ctx, applicationId, branch, decision) {
2834
+ if (decision) {
2835
+ return decision;
2836
+ }
2837
+ const plan = ctx.appState.pendingPlan(applicationId, branch);
2838
+ return plan && plan.branch === branch
2839
+ ? {
2840
+ kind: "plan",
2841
+ ...(plan.messageId ? { messageId: plan.messageId } : {}),
2842
+ plan: plan.plan,
2843
+ ...(plan.title ? { title: plan.title } : {}),
2844
+ }
2845
+ : undefined;
2846
+ }
2847
+ /**
2848
+ * Opens the decision card: what Superblocks asked, for the user to answer as a
2849
+ * form rather than by typing an approval the host has to interpret.
2850
+ *
2851
+ * Its own tool because an MCP App is bound to one, and `check_app_progress`
2852
+ * must stay text-only - a card tool reopens its card on every poll, so putting
2853
+ * this on the poll loop would flash a blank form through a whole build.
2854
+ */
2855
+ export async function askUser(ctx, input) {
2856
+ const principalResult = await ensurePrincipal(ctx);
2857
+ if (principalResult.kind !== "ok") {
2858
+ return principalResult;
2859
+ }
2860
+ const principal = principalResult.value;
2861
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
2862
+ if (resolved.kind !== "ok") {
2863
+ return resolved;
2864
+ }
2865
+ const applicationId = resolved.value;
2866
+ const unbuilt = await unbuiltApp(ctx, principal, applicationId, input.branch);
2867
+ const branch = unbuilt?.value.branch ??
2868
+ currentTurnBranch(ctx, principal, applicationId, input.branch);
2869
+ const app = {
2870
+ applicationId,
2871
+ ...(branch ? { branch } : {}),
2872
+ editUrl: editUrl(ctx.config, applicationId, branch),
2873
+ ...(unbuilt?.value.name ? { name: unbuilt.value.name } : {}),
2874
+ };
2875
+ if (!unbuilt || unbuilt.value.status !== "needs_decision") {
2876
+ // Nothing to ask. Saying which status it is instead is what stops the
2877
+ // caller reopening an empty form rather than following the build.
2878
+ return {
2879
+ kind: "ok",
2880
+ value: {
2881
+ ...app,
2882
+ ...(unbuilt?.value.pendingAction
2883
+ ? { pendingAction: unbuilt.value.pendingAction }
2884
+ : {}),
2885
+ status: unbuilt ? unbuilt.value.status : "ready",
2886
+ },
2887
+ };
2888
+ }
2889
+ const decision = decisionToPutToTheUser(ctx, applicationId, unbuilt.value.branch ?? normalizeBranch(input.branch), unbuilt.value.decision);
2890
+ const candidate = ctx.appState.pendingDecision(applicationId, branch);
2891
+ const pending = candidate?.branch === branch ? candidate : undefined;
2892
+ return {
2893
+ kind: "ok",
2894
+ value: {
2895
+ ...app,
2896
+ ...(decision ? { decision } : {}),
2897
+ pendingAction: pendingActionForDecision(decision),
2898
+ ...(pending?.reply ? { reply: pending.reply } : {}),
2899
+ status: "needs_decision",
2900
+ },
2901
+ };
2902
+ }
2903
+ /**
2904
+ * Read the app's current live editor view without committing or building it.
2283
2905
  */
2284
2906
  export async function getApp(ctx, input) {
2285
2907
  const principalResult = await ensurePrincipal(ctx);
@@ -2292,25 +2914,32 @@ export async function getApp(ctx, input) {
2292
2914
  return resolved;
2293
2915
  }
2294
2916
  const applicationId = resolved.value;
2295
- const ensurePreview = input.ensurePreview !== false;
2296
- const unbuilt = await unbuiltApp(ctx, principal, applicationId);
2917
+ const includeScreenshot = input.includeScreenshot !== false;
2918
+ const unbuilt = await unbuiltApp(ctx, principal, applicationId, input.branch);
2919
+ const branch = unbuilt?.value.branch ??
2920
+ currentTurnBranch(ctx, principal, applicationId, input.branch);
2297
2921
  const mayCapture = !unbuilt || unbuilt.value.status === "building";
2298
- const live = mayCapture
2299
- ? await ensureLibraryCaptureInput(ctx, principal, applicationId, input.branch)
2300
- : libraryCaptureInput(ctx, applicationId);
2301
- if (unbuilt) {
2922
+ const live = includeScreenshot && mayCapture
2923
+ ? await ensureLibraryCaptureInput(ctx, principal, applicationId, branch)
2924
+ : undefined;
2925
+ const currentUnbuilt = includeScreenshot && mayCapture
2926
+ ? await unbuiltApp(ctx, principal, applicationId, input.branch)
2927
+ : unbuilt;
2928
+ if (currentUnbuilt) {
2302
2929
  // Plan, question, and empty-starter stays fail-closed: photographing the
2303
2930
  // untouched template is the bug unbuiltApp exists to prevent. A running
2304
2931
  // turn is different: the held canvas is the thing being written, so a
2305
2932
  // live shot is worth returning alongside wait_for_build.
2306
- if (unbuilt.value.status !== "building" || !live) {
2307
- return unbuilt;
2933
+ if (currentUnbuilt.value.status !== "building" ||
2934
+ !live ||
2935
+ (currentUnbuilt.value.branch ?? normalizeBranch(input.branch)) !== branch) {
2936
+ return currentUnbuilt;
2308
2937
  }
2309
2938
  const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2310
2939
  return {
2311
2940
  kind: "ok",
2312
2941
  value: {
2313
- ...unbuilt.value,
2942
+ ...currentUnbuilt.value,
2314
2943
  ...(liveShot.needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2315
2944
  ...(liveShot.screenshotBase64
2316
2945
  ? { screenshotBase64: liveShot.screenshotBase64 }
@@ -2318,64 +2947,24 @@ export async function getApp(ctx, input) {
2318
2947
  },
2319
2948
  };
2320
2949
  }
2321
- if (ensurePreview) {
2322
- const preview = await previewApp(ctx, {
2323
- applicationId,
2324
- branch: input.branch,
2325
- includeScreenshot: live === undefined,
2326
- ...(input.wait === undefined ? {} : { wait: input.wait }),
2327
- });
2328
- if (preview.kind !== "ok") {
2329
- return preview;
2330
- }
2331
- // A build still running is the case this pacing exists for: the caller
2332
- // has usable URLs and needs to know when to look again. A finished one
2333
- // gets it too, with a zero interval, so a caller does not have to treat
2334
- // the fields' absence as a third state.
2335
- const pacing = await buildPacing(ctx, {
2336
- applicationId,
2337
- organizationId: principal.organizationId,
2338
- terminal: preview.value.status === "ready",
2339
- });
2340
- const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2341
- const screenshotBase64 = live
2342
- ? liveShot.screenshotBase64
2343
- : preview.value.screenshotBase64;
2344
- const needsBrowserDriver = live
2345
- ? liveShot.needsBrowserDriver
2346
- : preview.value.needsBrowserDriver;
2347
- return {
2348
- kind: "ok",
2349
- value: {
2350
- ...pacing,
2351
- applicationId,
2352
- ...(preview.value.browser ? { browser: preview.value.browser } : {}),
2353
- commitId: preview.value.commitId,
2354
- editUrl: preview.value.editUrl,
2355
- ...(preview.value.name ? { name: preview.value.name } : {}),
2356
- ...(needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2357
- openableViaHttps: preview.value.openableViaHttps,
2358
- previewUrl: preview.value.previewUrl,
2359
- ...(screenshotBase64 ? { screenshotBase64 } : {}),
2360
- status: preview.value.status,
2361
- viewerUrl: preview.value.viewerUrl,
2362
- },
2363
- };
2364
- }
2365
2950
  const name = await applicationName(ctx, principal, applicationId);
2366
- await rememberApplication(ctx, principal, applicationId);
2951
+ await rememberApplication(ctx, principal, applicationId, branch);
2367
2952
  const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2953
+ const previewUrl = fullscreenPreviewUrl(ctx.config, applicationId, branch);
2368
2954
  return {
2369
2955
  kind: "ok",
2370
- value: await withBrowser(ctx, applicationId, {
2956
+ value: await withBrowser(ctx, {
2371
2957
  applicationId,
2372
- editUrl: editUrl(ctx.config, applicationId),
2958
+ ...(branch ? { branch } : {}),
2959
+ editUrl: editUrl(ctx.config, applicationId, branch),
2373
2960
  ...(name ? { name } : {}),
2374
2961
  ...(liveShot.needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2962
+ openableViaHttps: isHttpsUrl(previewUrl),
2963
+ previewUrl,
2375
2964
  ...(liveShot.screenshotBase64
2376
2965
  ? { screenshotBase64: liveShot.screenshotBase64 }
2377
2966
  : {}),
2378
- status: "unknown",
2967
+ status: "ready",
2379
2968
  }),
2380
2969
  };
2381
2970
  }
@@ -2394,7 +2983,7 @@ async function waitForBuild(ctx, input) {
2394
2983
  let started = false;
2395
2984
  for (;;) {
2396
2985
  if (ctx.signal?.aborted) {
2397
- return namingApplication(input.applicationId, cancelled("get_app"));
2986
+ return namingApplication(input.applicationId, cancelled("build_app"));
2398
2987
  }
2399
2988
  // Checked before the network call, not only after it: a budget already
2400
2989
  // exhausted when this loop begins (or used up by the previous
@@ -2472,20 +3061,258 @@ export async function publishApp(ctx, input) {
2472
3061
  return principalResult;
2473
3062
  }
2474
3063
  const principal = principalResult.value;
2475
- const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
3064
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId, { unambiguous: true });
2476
3065
  if (resolved.kind !== "ok") {
2477
3066
  return resolved;
2478
3067
  }
2479
3068
  const applicationId = resolved.value;
2480
3069
  return namingApplication(applicationId, await publishResolvedApp(ctx, { applicationId, input, principal }));
2481
3070
  }
3071
+ /**
3072
+ * The publish that was accepted and is still rolling out.
3073
+ *
3074
+ * Reported as a success rather than an error, because it is one: the commit is
3075
+ * made, the policy gates passed and Superblocks took the deploy. Only the
3076
+ * rollout is outstanding, and `check_publish_progress` follows it.
3077
+ */
3078
+ function stillPublishing(ctx, input) {
3079
+ ctx.onProgress?.({
3080
+ message: "Publish accepted — the deploy is still rolling out",
3081
+ type: "completed",
3082
+ });
3083
+ return {
3084
+ applicationId: input.applicationId,
3085
+ commitId: input.commitId,
3086
+ ...publishElapsedSeconds(ctx, input.applicationId, input.commitId, input.startedAtMs),
3087
+ publishDetailsUrl: publishDetailsUrl(ctx.config, input.applicationId, input.commitId),
3088
+ publishUrl: publishUrl(ctx.config, input.applicationId),
3089
+ status: "publishing",
3090
+ };
3091
+ }
3092
+ function publishElapsedSeconds(ctx, applicationId, commitId, startedAtMs = ctx.appState.publishStartedAtMs(applicationId, commitId)) {
3093
+ return startedAtMs === undefined
3094
+ ? {}
3095
+ : {
3096
+ elapsedSeconds: Math.floor(Math.max(0, nowMs(ctx) - startedAtMs) / 1000),
3097
+ };
3098
+ }
3099
+ /**
3100
+ * A deploy that was accepted but never appeared in the queue.
3101
+ *
3102
+ * Distinct from a rollout still running: there is nothing to follow, so the
3103
+ * caller is told to look rather than sent into a poll loop over a deploy that
3104
+ * may not exist.
3105
+ */
3106
+ function publishNeverQueued(ctx, applicationId) {
3107
+ return {
3108
+ kind: "error",
3109
+ code: "publish_timeout",
3110
+ message: `Timed out waiting for the app to go live (last status: none). Open ${publishUrl(ctx.config, applicationId)} to check progress.`,
3111
+ };
3112
+ }
3113
+ /**
3114
+ * Where a publish handed back as `publishing` has got to.
3115
+ *
3116
+ * Each call watches for a bounded slice of the rollout, then hands back a
3117
+ * changing clock so repeated calls remain useful without outliving the host.
3118
+ */
3119
+ export async function checkPublishProgress(ctx, input) {
3120
+ const pollStartedAtMs = nowMs(ctx);
3121
+ const hostDeadlineAtMs = pollStartedAtMs + Math.max(0, ctx.config.hostCallCeilingMs);
3122
+ const waitDeadlineAtMs = nowMs(ctx) + hostCallBudgetMs(ctx, ctx.config.publishProgressWaitMs);
3123
+ const principalResult = await ensurePrincipal(ctx);
3124
+ if (principalResult.kind !== "ok") {
3125
+ return principalResult;
3126
+ }
3127
+ const principal = principalResult.value;
3128
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
3129
+ if (resolved.kind !== "ok") {
3130
+ return resolved;
3131
+ }
3132
+ const applicationId = resolved.value;
3133
+ const storedCommitId = ctx.appState.publishInFlight(applicationId);
3134
+ const commitId = input.commitId?.trim() || storedCommitId;
3135
+ if (!commitId) {
3136
+ return namingApplication(applicationId, {
3137
+ kind: "error",
3138
+ code: "publish_not_started",
3139
+ message: "No publish is in flight for this application. Call publish_app to deploy it, or name the commitId to follow.",
3140
+ });
3141
+ }
3142
+ const claimedCommit = storedCommitId === undefined;
3143
+ const publishClaim = claimedCommit || storedCommitId === commitId
3144
+ ? ctx.appState.claimPublishInFlight(applicationId, commitId, nowMs(ctx))
3145
+ : undefined;
3146
+ const publishStartedAtMs = ctx.appState.publishStartedAtMs(applicationId, commitId);
3147
+ const traceScope = {
3148
+ "application.id": applicationId,
3149
+ "publish.commit_id": commitId,
3150
+ };
3151
+ gatewayDebug("publish poll started", {
3152
+ ...traceScope,
3153
+ "poll.host_budget_ms": Math.max(0, hostDeadlineAtMs - pollStartedAtMs),
3154
+ "poll.wait_budget_ms": Math.max(0, waitDeadlineAtMs - pollStartedAtMs),
3155
+ });
3156
+ let readCount = 0;
3157
+ let lastStatus = "unread";
3158
+ const traceFinished = (stoppedBecause) => {
3159
+ gatewayDebug("publish poll finished", {
3160
+ ...traceScope,
3161
+ "poll.elapsed_ms": nowMs(ctx) - pollStartedAtMs,
3162
+ "poll.read_count": readCount,
3163
+ "poll.stopped_because": stoppedBecause,
3164
+ "publish.deploy_status": lastStatus,
3165
+ });
3166
+ };
3167
+ const aborted = () => {
3168
+ if (claimedCommit && publishClaim !== undefined) {
3169
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3170
+ }
3171
+ traceFinished("cancelled");
3172
+ return namingApplication(applicationId, {
3173
+ kind: "error",
3174
+ code: "publish_aborted",
3175
+ message: "Publish wait was cancelled.",
3176
+ });
3177
+ };
3178
+ for (;;) {
3179
+ let status;
3180
+ try {
3181
+ const remainingHostMs = Math.max(1, hostDeadlineAtMs - nowMs(ctx));
3182
+ const timeoutMs = readCount === 0
3183
+ ? remainingHostMs
3184
+ : Math.max(1, Math.min(remainingHostMs, waitDeadlineAtMs - nowMs(ctx)));
3185
+ status = await ctx.server.getDeployStatus({
3186
+ apiKey: principal.apiKey,
3187
+ applicationId,
3188
+ commitId,
3189
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
3190
+ timeoutMs,
3191
+ });
3192
+ readCount += 1;
3193
+ lastStatus = status.status;
3194
+ gatewayDebug("publish poll status observed", {
3195
+ ...traceScope,
3196
+ "poll.read": readCount,
3197
+ "poll.wait_remaining_ms": Math.max(0, waitDeadlineAtMs - nowMs(ctx)),
3198
+ "publish.deploy_status": lastStatus,
3199
+ });
3200
+ }
3201
+ catch (error) {
3202
+ readCount += 1;
3203
+ if (ctx.signal?.aborted) {
3204
+ return aborted();
3205
+ }
3206
+ if (!deployStatusReadIsTransient(error)) {
3207
+ if (claimedCommit && publishClaim !== undefined) {
3208
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3209
+ }
3210
+ lastStatus = "read_failed";
3211
+ traceFinished("deploy_status_read_failed");
3212
+ return namingApplication(applicationId, {
3213
+ kind: "error",
3214
+ code: "publish_failed",
3215
+ message: error instanceof Error ? error.message : String(error),
3216
+ });
3217
+ }
3218
+ lastStatus = "transient_read_error";
3219
+ gatewayDebug("publish poll status observed", {
3220
+ ...traceScope,
3221
+ "poll.read": readCount,
3222
+ "poll.wait_remaining_ms": Math.max(0, waitDeadlineAtMs - nowMs(ctx)),
3223
+ "publish.deploy_status": lastStatus,
3224
+ });
3225
+ }
3226
+ if (status !== undefined) {
3227
+ const outcome = readDeployOutcome(status);
3228
+ if (outcome.kind === "failed" ||
3229
+ outcome.kind === "needs_acknowledgement") {
3230
+ if (publishClaim !== undefined) {
3231
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3232
+ }
3233
+ traceFinished(outcome.kind);
3234
+ return namingApplication(applicationId, deployFailure(outcome, outcome.message));
3235
+ }
3236
+ if (outcome.kind === "deployed") {
3237
+ const elapsed = publishElapsedSeconds(ctx, applicationId, commitId, publishStartedAtMs);
3238
+ if (publishClaim !== undefined) {
3239
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3240
+ }
3241
+ traceFinished("deployed");
3242
+ return {
3243
+ kind: "ok",
3244
+ value: {
3245
+ applicationId,
3246
+ commitId,
3247
+ ...elapsed,
3248
+ publishDetailsUrl: publishDetailsUrl(ctx.config, applicationId, commitId),
3249
+ publishUrl: publishUrl(ctx.config, applicationId),
3250
+ status: "deployed",
3251
+ },
3252
+ };
3253
+ }
3254
+ if (outcome.status === "none") {
3255
+ if (publishClaim === undefined) {
3256
+ traceFinished("publish_not_queued");
3257
+ return namingApplication(applicationId, publishNeverQueued(ctx, applicationId));
3258
+ }
3259
+ const missingForMs = ctx.appState.noteMissingPublishStatus(applicationId, publishClaim, nowMs(ctx));
3260
+ if (missingForMs !== undefined &&
3261
+ missingForMs >= ctx.config.publishWaitTimeoutMs) {
3262
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3263
+ traceFinished("publish_not_queued");
3264
+ return namingApplication(applicationId, publishNeverQueued(ctx, applicationId));
3265
+ }
3266
+ }
3267
+ else if (publishClaim !== undefined) {
3268
+ ctx.appState.noteVisiblePublishStatus(applicationId, publishClaim);
3269
+ }
3270
+ }
3271
+ if (ctx.signal?.aborted) {
3272
+ return aborted();
3273
+ }
3274
+ if (nowMs(ctx) >= waitDeadlineAtMs) {
3275
+ traceFinished("poll_budget_spent");
3276
+ return {
3277
+ kind: "ok",
3278
+ value: stillPublishing(ctx, {
3279
+ applicationId,
3280
+ commitId,
3281
+ startedAtMs: publishStartedAtMs,
3282
+ }),
3283
+ };
3284
+ }
3285
+ await sleepUntilAbort(Math.min(ctx.config.publishPollIntervalMs, Math.max(0, waitDeadlineAtMs - nowMs(ctx))), ctx.signal);
3286
+ if (ctx.signal?.aborted) {
3287
+ return aborted();
3288
+ }
3289
+ }
3290
+ }
2482
3291
  async function publishResolvedApp(ctx, args) {
2483
3292
  const { applicationId, input, principal } = args;
3293
+ const requestedCommitId = input.commitId?.trim();
3294
+ // One budget for the whole call, not just the rollout wait: committing and
3295
+ // queueing the deploy spend the host's tool-call time too.
3296
+ const deadlineAtMs = nowMs(ctx) + hostCallBudgetMs(ctx, ctx.config.publishWaitTimeoutMs);
3297
+ if (ctx.signal?.aborted) {
3298
+ return {
3299
+ kind: "error",
3300
+ code: "publish_aborted",
3301
+ message: "Publish was cancelled before it started.",
3302
+ };
3303
+ }
3304
+ if (nowMs(ctx) >= deadlineAtMs) {
3305
+ return {
3306
+ kind: "error",
3307
+ code: "publish_timeout",
3308
+ message: "Publish could not start within the host call budget.",
3309
+ };
3310
+ }
2484
3311
  // Deploys address a commit and never fall back to the branch head, while
2485
3312
  // Clark's work sits uncommitted in the live-edit session. Committing here is
2486
3313
  // what the editor's own publish flow does, and without it a caller who has
2487
3314
  // never committed could not publish at all.
2488
- let commitId = input.commitId?.trim();
3315
+ let commitId = requestedCommitId;
2489
3316
  if (!commitId) {
2490
3317
  ctx.onProgress?.({ message: "Committing current work", type: "progress" });
2491
3318
  try {
@@ -2494,10 +3321,19 @@ async function publishResolvedApp(ctx, args) {
2494
3321
  applicationId,
2495
3322
  branch: input.branch,
2496
3323
  message: input.prompt,
3324
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
3325
+ timeoutMs: Math.max(1, deadlineAtMs - nowMs(ctx)),
2497
3326
  });
2498
3327
  commitId = commit.commitId;
2499
3328
  }
2500
3329
  catch (error) {
3330
+ if (ctx.signal?.aborted) {
3331
+ return {
3332
+ kind: "error",
3333
+ code: "publish_aborted",
3334
+ message: "Publish was cancelled while committing the app.",
3335
+ };
3336
+ }
2501
3337
  return {
2502
3338
  kind: "error",
2503
3339
  code: "commit_failed",
@@ -2505,17 +3341,35 @@ async function publishResolvedApp(ctx, args) {
2505
3341
  };
2506
3342
  }
2507
3343
  }
3344
+ if (ctx.signal?.aborted) {
3345
+ return {
3346
+ kind: "error",
3347
+ code: "publish_aborted",
3348
+ message: `Commit ${commitId} was created, but deployment was cancelled before it started. Retry publish_app with that commitId.`,
3349
+ };
3350
+ }
3351
+ if (nowMs(ctx) >= deadlineAtMs) {
3352
+ return {
3353
+ kind: "error",
3354
+ code: "publish_timeout",
3355
+ message: `Commit ${commitId} was created, but deployment could not start within the host call budget. Retry publish_app with that commitId.`,
3356
+ };
3357
+ }
2508
3358
  ctx.onProgress?.({ message: "Publishing application", type: "progress" });
3359
+ const publishClaim = ctx.appState.setPublishInFlight(applicationId, commitId, nowMs(ctx));
2509
3360
  try {
2510
3361
  const deployment = await ctx.server.deployApplication({
2511
3362
  apiKey: principal.apiKey,
2512
3363
  applicationId,
2513
3364
  commitId,
3365
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
3366
+ timeoutMs: Math.max(1, deadlineAtMs - nowMs(ctx)),
2514
3367
  });
2515
3368
  // Nothing is published on these two paths and nothing will be without
2516
3369
  // someone acting, so neither can report as success.
2517
3370
  if (deployment.status === "failed") {
2518
- ctx.sessionPeer.release?.(applicationId);
3371
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3372
+ ctx.sessionPeer.release(applicationId);
2519
3373
  return {
2520
3374
  kind: "error",
2521
3375
  code: "publish_failed",
@@ -2523,14 +3377,14 @@ async function publishResolvedApp(ctx, args) {
2523
3377
  };
2524
3378
  }
2525
3379
  if (deployment.status === "awaiting_ack") {
2526
- ctx.sessionPeer.release?.(applicationId);
3380
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3381
+ ctx.sessionPeer.release(applicationId);
2527
3382
  return {
2528
3383
  kind: "error",
2529
3384
  code: "publish_needs_acknowledgement",
2530
3385
  message: `${deployment.message ?? "Policy checks passed with advisory findings."} Publishing is on hold until they are reviewed in Superblocks, or until this is published again with the findings acknowledged.`,
2531
3386
  };
2532
3387
  }
2533
- const deploymentId = deployment.deploymentId;
2534
3388
  if (deployment.status === "queued") {
2535
3389
  ctx.onProgress?.({
2536
3390
  message: "Waiting for deploy…",
@@ -2540,19 +3394,41 @@ async function publishResolvedApp(ctx, args) {
2540
3394
  apiKey: principal.apiKey,
2541
3395
  applicationId,
2542
3396
  commitId,
3397
+ deadlineAtMs,
3398
+ publishClaim,
2543
3399
  });
2544
3400
  if (waited.kind !== "ok") {
2545
- ctx.sessionPeer.release?.(applicationId);
3401
+ ctx.sessionPeer.release(applicationId);
2546
3402
  return waited;
2547
3403
  }
3404
+ if (!waited.value.settled) {
3405
+ ctx.sessionPeer.release(applicationId);
3406
+ // Nothing ever appeared in the deploy queue, so there is no rollout to
3407
+ // follow and telling the caller to keep polling would be inventing one.
3408
+ if (waited.value.lastStatus === "none") {
3409
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3410
+ return publishNeverQueued(ctx, applicationId);
3411
+ }
3412
+ await rememberApplication(ctx, principal, applicationId);
3413
+ return {
3414
+ kind: "ok",
3415
+ value: stillPublishing(ctx, {
3416
+ applicationId,
3417
+ commitId,
3418
+ }),
3419
+ };
3420
+ }
2548
3421
  }
2549
3422
  await rememberApplication(ctx, principal, applicationId);
2550
3423
  // Deploy finished — the held editor socket is no longer needed.
2551
- ctx.sessionPeer.release?.(applicationId);
3424
+ ctx.sessionPeer.release(applicationId);
3425
+ const elapsed = publishElapsedSeconds(ctx, applicationId, commitId);
3426
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
2552
3427
  const result = {
2553
3428
  applicationId,
2554
3429
  commitId,
2555
- deploymentId,
3430
+ ...elapsed,
3431
+ publishDetailsUrl: publishDetailsUrl(ctx.config, applicationId, commitId),
2556
3432
  publishUrl: publishUrl(ctx.config, applicationId),
2557
3433
  status: "deployed",
2558
3434
  };
@@ -2568,12 +3444,24 @@ async function publishResolvedApp(ctx, args) {
2568
3444
  // mentioning an author through a re-auth flow and bury the real failure.
2569
3445
  if (error instanceof ServerHttpError &&
2570
3446
  (error.status === 401 || error.status === 403)) {
2571
- return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, applicationId);
3447
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3448
+ return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, applicationId, input.branch);
3449
+ }
3450
+ if (error instanceof ServerHttpError) {
3451
+ ctx.appState.clearPublishInFlight(applicationId, publishClaim);
3452
+ return {
3453
+ kind: "error",
3454
+ code: "publish_failed",
3455
+ message: error.message,
3456
+ };
2572
3457
  }
3458
+ ctx.sessionPeer.release(applicationId);
2573
3459
  return {
2574
- kind: "error",
2575
- code: "publish_failed",
2576
- message: error instanceof Error ? error.message : String(error),
3460
+ kind: "ok",
3461
+ value: stillPublishing(ctx, {
3462
+ applicationId,
3463
+ commitId,
3464
+ }),
2577
3465
  };
2578
3466
  }
2579
3467
  }