@superblocksteam/gateway 2.0.0-SNAPSHOT.1d4c72c0ab174c21

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