@superblocksteam/gateway 2.0.155-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE.txt +87 -0
  2. package/README.md +170 -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/import-prompt.d.ts +11 -0
  7. package/dist/capabilities/import-prompt.js +96 -0
  8. package/dist/capabilities/import-prompt.js.map +1 -0
  9. package/dist/capabilities/lifecycle.d.ts +73 -0
  10. package/dist/capabilities/lifecycle.js +2531 -0
  11. package/dist/capabilities/lifecycle.js.map +1 -0
  12. package/dist/capabilities/persisted-progress.d.ts +46 -0
  13. package/dist/capabilities/persisted-progress.js +246 -0
  14. package/dist/capabilities/persisted-progress.js.map +1 -0
  15. package/dist/capabilities/types.d.ts +446 -0
  16. package/dist/capabilities/types.js +47 -0
  17. package/dist/capabilities/types.js.map +1 -0
  18. package/dist/capture/browser-contract.d.ts +36 -0
  19. package/dist/capture/browser-contract.js +24 -0
  20. package/dist/capture/browser-contract.js.map +1 -0
  21. package/dist/capture/browser-instructions.d.ts +9 -0
  22. package/dist/capture/browser-instructions.js +17 -0
  23. package/dist/capture/browser-instructions.js.map +1 -0
  24. package/dist/capture/capture-library-probe.d.ts +55 -0
  25. package/dist/capture/capture-library-probe.js +177 -0
  26. package/dist/capture/capture-library-probe.js.map +1 -0
  27. package/dist/capture/capture-library.d.ts +52 -0
  28. package/dist/capture/capture-library.js +249 -0
  29. package/dist/capture/capture-library.js.map +1 -0
  30. package/dist/capture/classify-library-paint.d.ts +21 -0
  31. package/dist/capture/classify-library-paint.js +13 -0
  32. package/dist/capture/classify-library-paint.js.map +1 -0
  33. package/dist/capture/host-mcp-scan.d.ts +16 -0
  34. package/dist/capture/host-mcp-scan.js +64 -0
  35. package/dist/capture/host-mcp-scan.js.map +1 -0
  36. package/dist/capture/library-parent-script.d.ts +34 -0
  37. package/dist/capture/library-parent-script.js +82 -0
  38. package/dist/capture/library-parent-script.js.map +1 -0
  39. package/dist/capture/mode.d.ts +22 -0
  40. package/dist/capture/mode.js +45 -0
  41. package/dist/capture/mode.js.map +1 -0
  42. package/dist/config.d.ts +141 -0
  43. package/dist/config.js +109 -0
  44. package/dist/config.js.map +1 -0
  45. package/dist/debug.d.ts +2 -0
  46. package/dist/debug.js +9 -0
  47. package/dist/debug.js.map +1 -0
  48. package/dist/deps.d.ts +36 -0
  49. package/dist/deps.js +2 -0
  50. package/dist/deps.js.map +1 -0
  51. package/dist/events/cursor.d.ts +43 -0
  52. package/dist/events/cursor.js +78 -0
  53. package/dist/events/cursor.js.map +1 -0
  54. package/dist/events/memory-event-store.d.ts +34 -0
  55. package/dist/events/memory-event-store.js +110 -0
  56. package/dist/events/memory-event-store.js.map +1 -0
  57. package/dist/events/merge.d.ts +23 -0
  58. package/dist/events/merge.js +97 -0
  59. package/dist/events/merge.js.map +1 -0
  60. package/dist/events/normalized-collector.d.ts +62 -0
  61. package/dist/events/normalized-collector.js +156 -0
  62. package/dist/events/normalized-collector.js.map +1 -0
  63. package/dist/events/schema.d.ts +9 -0
  64. package/dist/events/schema.js +93 -0
  65. package/dist/events/schema.js.map +1 -0
  66. package/dist/events/snapshot.d.ts +32 -0
  67. package/dist/events/snapshot.js +57 -0
  68. package/dist/events/snapshot.js.map +1 -0
  69. package/dist/events/stream-key.d.ts +2 -0
  70. package/dist/events/stream-key.js +31 -0
  71. package/dist/events/stream-key.js.map +1 -0
  72. package/dist/events/types.d.ts +179 -0
  73. package/dist/events/types.js +66 -0
  74. package/dist/events/types.js.map +1 -0
  75. package/dist/health/readiness.d.ts +99 -0
  76. package/dist/health/readiness.js +202 -0
  77. package/dist/health/readiness.js.map +1 -0
  78. package/dist/index.d.ts +10 -0
  79. package/dist/index.js +10 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/integrations/filters.d.ts +8 -0
  82. package/dist/integrations/filters.js +40 -0
  83. package/dist/integrations/filters.js.map +1 -0
  84. package/dist/integrations/map.d.ts +26 -0
  85. package/dist/integrations/map.js +43 -0
  86. package/dist/integrations/map.js.map +1 -0
  87. package/dist/integrations/profile-key.d.ts +19 -0
  88. package/dist/integrations/profile-key.js +35 -0
  89. package/dist/integrations/profile-key.js.map +1 -0
  90. package/dist/main.d.ts +2 -0
  91. package/dist/main.js +5 -0
  92. package/dist/main.js.map +1 -0
  93. package/dist/orchestrator/client.d.ts +62 -0
  94. package/dist/orchestrator/client.js +160 -0
  95. package/dist/orchestrator/client.js.map +1 -0
  96. package/dist/playwright/ensure-chromium.d.ts +26 -0
  97. package/dist/playwright/ensure-chromium.js +60 -0
  98. package/dist/playwright/ensure-chromium.js.map +1 -0
  99. package/dist/preview/capture-screenshot.d.ts +52 -0
  100. package/dist/preview/capture-screenshot.js +158 -0
  101. package/dist/preview/capture-screenshot.js.map +1 -0
  102. package/dist/preview/viewer-url.d.ts +82 -0
  103. package/dist/preview/viewer-url.js +128 -0
  104. package/dist/preview/viewer-url.js.map +1 -0
  105. package/dist/process/fault-barrier.d.ts +55 -0
  106. package/dist/process/fault-barrier.js +113 -0
  107. package/dist/process/fault-barrier.js.map +1 -0
  108. package/dist/resume/memory-progress-store.d.ts +39 -0
  109. package/dist/resume/memory-progress-store.js +82 -0
  110. package/dist/resume/memory-progress-store.js.map +1 -0
  111. package/dist/resume/memory-recent-app-store.d.ts +14 -0
  112. package/dist/resume/memory-recent-app-store.js +27 -0
  113. package/dist/resume/memory-recent-app-store.js.map +1 -0
  114. package/dist/resume/memory-turn-store.d.ts +18 -0
  115. package/dist/resume/memory-turn-store.js +73 -0
  116. package/dist/resume/memory-turn-store.js.map +1 -0
  117. package/dist/resume/progress-key.d.ts +21 -0
  118. package/dist/resume/progress-key.js +58 -0
  119. package/dist/resume/progress-key.js.map +1 -0
  120. package/dist/resume/stores.d.ts +14 -0
  121. package/dist/resume/stores.js +18 -0
  122. package/dist/resume/stores.js.map +1 -0
  123. package/dist/resume/types.d.ts +124 -0
  124. package/dist/resume/types.js +13 -0
  125. package/dist/resume/types.js.map +1 -0
  126. package/dist/sabs/agent-facing-text.d.ts +33 -0
  127. package/dist/sabs/agent-facing-text.js +125 -0
  128. package/dist/sabs/agent-facing-text.js.map +1 -0
  129. package/dist/sabs/browser-context.d.ts +53 -0
  130. package/dist/sabs/browser-context.js +91 -0
  131. package/dist/sabs/browser-context.js.map +1 -0
  132. package/dist/sabs/editor-client-methods.d.ts +147 -0
  133. package/dist/sabs/editor-client-methods.js +715 -0
  134. package/dist/sabs/editor-client-methods.js.map +1 -0
  135. package/dist/sabs/recent-tool-runs.d.ts +14 -0
  136. package/dist/sabs/recent-tool-runs.js +61 -0
  137. package/dist/sabs/recent-tool-runs.js.map +1 -0
  138. package/dist/sabs/session-peer.d.ts +351 -0
  139. package/dist/sabs/session-peer.js +2 -0
  140. package/dist/sabs/session-peer.js.map +1 -0
  141. package/dist/sabs/turn-collector.d.ts +152 -0
  142. package/dist/sabs/turn-collector.js +573 -0
  143. package/dist/sabs/turn-collector.js.map +1 -0
  144. package/dist/sabs/websocket-session-peer.d.ts +514 -0
  145. package/dist/sabs/websocket-session-peer.js +2487 -0
  146. package/dist/sabs/websocket-session-peer.js.map +1 -0
  147. package/dist/security/constant-time.d.ts +9 -0
  148. package/dist/security/constant-time.js +13 -0
  149. package/dist/security/constant-time.js.map +1 -0
  150. package/dist/server/client.d.ts +371 -0
  151. package/dist/server/client.js +572 -0
  152. package/dist/server/client.js.map +1 -0
  153. package/dist/server/identity-reads.d.ts +76 -0
  154. package/dist/server/identity-reads.js +209 -0
  155. package/dist/server/identity-reads.js.map +1 -0
  156. package/dist/start.d.ts +14 -0
  157. package/dist/start.js +75 -0
  158. package/dist/start.js.map +1 -0
  159. package/dist/telemetry/metrics.d.ts +32 -0
  160. package/dist/telemetry/metrics.js +57 -0
  161. package/dist/telemetry/metrics.js.map +1 -0
  162. package/dist/transports/mcp/admin-tools.d.ts +47 -0
  163. package/dist/transports/mcp/admin-tools.js +164 -0
  164. package/dist/transports/mcp/admin-tools.js.map +1 -0
  165. package/dist/transports/mcp/app-status-html.d.ts +15 -0
  166. package/dist/transports/mcp/app-status-html.js +563 -0
  167. package/dist/transports/mcp/app-status-html.js.map +1 -0
  168. package/dist/transports/mcp/decision-elicitation.d.ts +74 -0
  169. package/dist/transports/mcp/decision-elicitation.js +233 -0
  170. package/dist/transports/mcp/decision-elicitation.js.map +1 -0
  171. package/dist/transports/mcp/dispatch-probe.d.ts +24 -0
  172. package/dist/transports/mcp/dispatch-probe.js +48 -0
  173. package/dist/transports/mcp/dispatch-probe.js.map +1 -0
  174. package/dist/transports/mcp/format-tool-content.d.ts +25 -0
  175. package/dist/transports/mcp/format-tool-content.js +48 -0
  176. package/dist/transports/mcp/format-tool-content.js.map +1 -0
  177. package/dist/transports/mcp/mount.d.ts +9 -0
  178. package/dist/transports/mcp/mount.js +469 -0
  179. package/dist/transports/mcp/mount.js.map +1 -0
  180. package/dist/transports/mcp/progress-notifier.d.ts +39 -0
  181. package/dist/transports/mcp/progress-notifier.js +52 -0
  182. package/dist/transports/mcp/progress-notifier.js.map +1 -0
  183. package/package.json +72 -0
@@ -0,0 +1,2531 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { detectAttachmentArchiveType } from "@superblocksteam/shared";
5
+ import { buildBrowserDriverContract, needsBrowserDriverError, } from "../capture/browser-contract.js";
6
+ import { resolveCaptureMode } from "../capture/mode.js";
7
+ import { EMPTY_EVENT_CURSOR, parseEventCursor } from "../events/cursor.js";
8
+ import { playwrightChromiumPresent } from "../playwright/ensure-chromium.js";
9
+ import { buildEmbedShellPreviewUrl, buildOpenablePreviewUrls, } from "../preview/viewer-url.js";
10
+ import { ServerHttpError } from "../server/client.js";
11
+ import { IdentityUnavailableError } from "../server/identity-reads.js";
12
+ import { buildImportPrompt } from "./import-prompt.js";
13
+ import { readPersistedProgress } from "./persisted-progress.js";
14
+ import { buildStatusOf } from "./types.js";
15
+ import { IMPORT_ZIP_MAX_BYTES } from "./types.js";
16
+ export { IMPORT_ZIP_MAX_BYTES } from "./types.js";
17
+ const APPLICATION_CONTEXT_REQUIRED = "No applicationId provided and no recent application is available. Call start_app first or pass applicationId.";
18
+ /**
19
+ * Every app the gateway starts is a code-mode app (`createUsingReact`), which
20
+ * the UI serves from `/code-mode` — the legacy `/applications/:id/edit` route
21
+ * does not open it.
22
+ */
23
+ function editUrl(config, applicationId) {
24
+ return `${config.uiBaseUrl.replace(/\/$/, "")}/code-mode/applications/edit/${applicationId}`;
25
+ }
26
+ function uiOrigin(config) {
27
+ return config.uiBaseUrl.replace(/\/$/, "");
28
+ }
29
+ async function gatewayChromiumIsPresent(ctx) {
30
+ if (typeof ctx.config.gatewayChromiumPresent === "boolean") {
31
+ return ctx.config.gatewayChromiumPresent;
32
+ }
33
+ return playwrightChromiumPresent();
34
+ }
35
+ async function captureModeOf(ctx) {
36
+ return resolveCaptureMode({
37
+ gatewayChromium: await gatewayChromiumIsPresent(ctx),
38
+ hostMcpConfigured: ctx.config.captureHostMcp,
39
+ });
40
+ }
41
+ function libraryCaptureInput(ctx, applicationId) {
42
+ const context = ctx.sessionPeer.libraryBrowserContext?.(applicationId);
43
+ if (!context) {
44
+ return undefined;
45
+ }
46
+ const auth = context.initData.devServerAuthorization;
47
+ if (!auth) {
48
+ console.error("gateway live canvas skipped: missing_dev_server_authorization", { applicationId });
49
+ return undefined;
50
+ }
51
+ return {
52
+ bootstrapData: {
53
+ accessToken: context.bootstrapData.accessToken,
54
+ agents: context.bootstrapData.agents ?? [],
55
+ ...(context.bootstrapData.agentUrls
56
+ ? { agentUrls: context.bootstrapData.agentUrls }
57
+ : {}),
58
+ appId: context.bootstrapData.appId,
59
+ ...(context.bootstrapData.featureFlags
60
+ ? { featureFlags: context.bootstrapData.featureFlags }
61
+ : {}),
62
+ ...(context.bootstrapData.orgId
63
+ ? { orgId: context.bootstrapData.orgId }
64
+ : {}),
65
+ ...(context.bootstrapData.profiles
66
+ ? { profiles: context.bootstrapData.profiles }
67
+ : {}),
68
+ token: context.bootstrapData.token,
69
+ ...(context.bootstrapData.userEmail
70
+ ? { userEmail: context.bootstrapData.userEmail }
71
+ : {}),
72
+ userId: context.bootstrapData.userId,
73
+ ...(context.bootstrapData.windowOriginUrl
74
+ ? { windowOriginUrl: context.bootstrapData.windowOriginUrl }
75
+ : {}),
76
+ },
77
+ iframeBaseUrl: context.iframeBaseUrl,
78
+ initData: {
79
+ appId: context.initData.appId,
80
+ devServerAuthorization: auth,
81
+ peerId: context.initData.peerId,
82
+ ...(context.initData.userId ? { userId: context.initData.userId } : {}),
83
+ ...(context.initData.windowOriginUrl
84
+ ? { windowOriginUrl: context.initData.windowOriginUrl }
85
+ : {}),
86
+ },
87
+ };
88
+ }
89
+ /**
90
+ * Live-library capture input for get_app: reuse a held session, or re-open
91
+ * live-edit when the in-memory hold is empty (MCP respawn / tool timeout).
92
+ */
93
+ async function ensureLibraryCaptureInput(ctx, principal, applicationId, branch) {
94
+ const existing = libraryCaptureInput(ctx, applicationId);
95
+ if (existing) {
96
+ return existing;
97
+ }
98
+ if (!ctx.sessionPeer.holdLiveEdit) {
99
+ console.error("gateway live canvas skipped: hold_live_edit_unavailable", {
100
+ applicationId,
101
+ });
102
+ return undefined;
103
+ }
104
+ if (!(await gatewayChromiumIsPresent(ctx))) {
105
+ console.error("gateway live canvas skipped: chromium_absent_no_rehold", {
106
+ applicationId,
107
+ });
108
+ return undefined;
109
+ }
110
+ try {
111
+ const session = await openLiveEdit(ctx, {
112
+ apiKey: principal.apiKey,
113
+ applicationId,
114
+ branch,
115
+ organizationId: principal.organizationId,
116
+ });
117
+ const sessionToken = await ctx.server.issueScopedToken({
118
+ apiKey: principal.apiKey,
119
+ applicationId,
120
+ });
121
+ await ctx.sessionPeer.holdLiveEdit({
122
+ apiKey: principal.apiKey,
123
+ applicationId,
124
+ branch,
125
+ keepalive: async () => {
126
+ await ctx.server.connectLiveEdit({
127
+ apiKey: principal.apiKey,
128
+ applicationId,
129
+ branch,
130
+ });
131
+ },
132
+ liveEditUrl: session.liveEditUrl,
133
+ organizationId: principal.organizationId,
134
+ sessionToken,
135
+ userId: principal.userId,
136
+ });
137
+ }
138
+ catch (error) {
139
+ console.error("gateway live canvas skipped: rehold_failed", {
140
+ applicationId,
141
+ message: error instanceof Error ? error.message : String(error),
142
+ });
143
+ return undefined;
144
+ }
145
+ const live = libraryCaptureInput(ctx, applicationId);
146
+ if (!live) {
147
+ console.error("gateway live canvas skipped: context_incomplete_after_rehold", { applicationId });
148
+ }
149
+ return live;
150
+ }
151
+ async function captureLiveLibraryIfNeeded(ctx, applicationId, live) {
152
+ if (!live) {
153
+ return {};
154
+ }
155
+ if (!ctx.captureLibraryScreenshot) {
156
+ console.error("gateway live canvas skipped: no_capture_function", {
157
+ applicationId,
158
+ });
159
+ return { needsBrowserDriver: true };
160
+ }
161
+ if (!(await gatewayChromiumIsPresent(ctx))) {
162
+ console.error("gateway live canvas skipped: chromium_absent", {
163
+ applicationId,
164
+ });
165
+ return {
166
+ needsBrowserDriver: true,
167
+ };
168
+ }
169
+ console.error("Capturing a live canvas screenshot", { applicationId });
170
+ ctx.onProgress?.({
171
+ message: "Capturing a live canvas screenshot",
172
+ type: "progress",
173
+ });
174
+ try {
175
+ const shot = await ctx.captureLibraryScreenshot(live);
176
+ return { screenshotBase64: shot?.dataBase64 };
177
+ }
178
+ catch (error) {
179
+ console.warn("gateway library screenshot threw; continuing with URLs", {
180
+ message: error instanceof Error ? error.message : String(error),
181
+ });
182
+ return { needsBrowserDriver: true };
183
+ }
184
+ }
185
+ async function browserContractFor(ctx, applicationId) {
186
+ return buildBrowserDriverContract({
187
+ captureMode: await captureModeOf(ctx),
188
+ editUrl: editUrl(ctx.config, applicationId),
189
+ isolated: ctx.config.captureHostIsolated,
190
+ uiOrigin: uiOrigin(ctx.config),
191
+ });
192
+ }
193
+ async function withBrowser(ctx, applicationId, value) {
194
+ return {
195
+ ...value,
196
+ browser: await browserContractFor(ctx, applicationId),
197
+ };
198
+ }
199
+ /**
200
+ * Where the deployed app is served; `/deployments` is an API path, not a page.
201
+ * Needs the same `/code-mode` prefix as {@link editUrl}: the bare
202
+ * `/applications/:id` route is proxied to the legacy SPA, which 404s.
203
+ */
204
+ function publishUrl(config, applicationId) {
205
+ return `${config.uiBaseUrl.replace(/\/$/, "")}/code-mode/applications/${applicationId}`;
206
+ }
207
+ /**
208
+ * Where a built, undeployed commit can be opened — what the editor's Preview
209
+ * button hands the browser. Distinct from {@link publishUrl}: this serves a
210
+ * specific commit and needs nothing deployed.
211
+ */
212
+ function previewUrl(config, applicationId, commitId) {
213
+ return `${config.uiBaseUrl.replace(/\/$/, "")}/code-mode/applications/preview/${applicationId}?commitId=${encodeURIComponent(commitId)}`;
214
+ }
215
+ function defaultAppName(prompt) {
216
+ const trimmed = prompt.trim().replace(/\s+/g, " ");
217
+ if (!trimmed) {
218
+ return "Untitled Gateway App";
219
+ }
220
+ return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
221
+ }
222
+ /**
223
+ * Resolves the Superblocks user behind the CLI session this Gateway was
224
+ * started with.
225
+ *
226
+ * Identity is the already-logged-in Superblocks CLI user. A rejection here
227
+ * means that personal API key is bad, not that the user needs a second login,
228
+ * so it surfaces as an error rather than an interactive-auth elicitation.
229
+ */
230
+ async function ensurePrincipal(ctx) {
231
+ const { principal } = ctx;
232
+ // MCP already has the CLI session key. Looking it up again would spend a
233
+ // second round trip on `/users/me`, the slowest endpoint the Superblocks
234
+ // API has, when the principal is already resolved (ENG-5806).
235
+ if (principal.organizationId && principal.userId) {
236
+ return {
237
+ kind: "ok",
238
+ value: {
239
+ ...principal,
240
+ organizationId: principal.organizationId,
241
+ userId: principal.userId,
242
+ },
243
+ };
244
+ }
245
+ let validated;
246
+ try {
247
+ validated = await ctx.server.validateApiKey(principal.apiKey);
248
+ }
249
+ catch (error) {
250
+ // A 5xx says nothing about the credential. Reporting it as an auth problem
251
+ // would send the user off to re-link an account that is perfectly fine.
252
+ if (error instanceof ServerHttpError && error.status >= 500) {
253
+ return {
254
+ kind: "error",
255
+ code: "downstream_unavailable",
256
+ message: `The Superblocks API could not confirm who this caller is: ${error.message}. This is usually transient — try again.`,
257
+ };
258
+ }
259
+ // The identity endpoint stopped answering. Same class of outage as the 5xx
260
+ // above, and it must not fall through to `capability_failed`: that told the
261
+ // caller its progress poll had failed on an identity route it never asked
262
+ // about, which reads as a broken gateway rather than a slow dependency.
263
+ if (error instanceof IdentityUnavailableError) {
264
+ return {
265
+ kind: "error",
266
+ code: "downstream_unavailable",
267
+ message: error.message,
268
+ };
269
+ }
270
+ throw error;
271
+ }
272
+ if (!validated) {
273
+ return {
274
+ kind: "error",
275
+ code: "downstream_unauthorized",
276
+ message: "The Superblocks API rejected the credential issued for this caller.",
277
+ };
278
+ }
279
+ return {
280
+ kind: "ok",
281
+ value: {
282
+ ...principal,
283
+ email: validated.email,
284
+ organizationId: validated.organizationId,
285
+ userId: validated.userId,
286
+ },
287
+ };
288
+ }
289
+ /**
290
+ * Names a freshly created app the way the editor does: create it under a
291
+ * placeholder, then let Clark propose something readable.
292
+ *
293
+ * Best-effort by design. The AI endpoint is behind an org kill switch and is a
294
+ * network call like any other; a working application is not worth discarding
295
+ * over a cosmetic name.
296
+ */
297
+ async function nameWithClark(ctx, input) {
298
+ try {
299
+ const name = await ctx.server.generateAppName({
300
+ apiKey: input.apiKey,
301
+ applicationId: input.applicationId,
302
+ prompt: input.prompt,
303
+ });
304
+ await ctx.server.renameApplication({
305
+ apiKey: input.apiKey,
306
+ applicationId: input.applicationId,
307
+ name,
308
+ });
309
+ return name;
310
+ }
311
+ catch (error) {
312
+ console.warn("gateway could not name the application with Superblocks", error);
313
+ return undefined;
314
+ }
315
+ }
316
+ /**
317
+ * The name the app ends up with: whatever the caller asked for, or Clark's
318
+ * suggestion. Asked for only after the prompt is on its way so Clark starts
319
+ * building immediately.
320
+ */
321
+ async function finalAppName(ctx, input) {
322
+ if (input.explicitName) {
323
+ return input.name;
324
+ }
325
+ ctx.onProgress?.({ message: "Naming application", type: "progress" });
326
+ return ((await nameWithClark(ctx, {
327
+ apiKey: input.apiKey,
328
+ applicationId: input.applicationId,
329
+ prompt: input.prompt,
330
+ })) ?? input.name);
331
+ }
332
+ async function elicitStepUp(ctx, idempotencyKey, applicationId) {
333
+ if (!ctx.config.captureHostMcp) {
334
+ return needsBrowserDriverError();
335
+ }
336
+ const existing = await ctx.turns.getByIdempotencyKey(idempotencyKey);
337
+ if (existing?.status === "ready") {
338
+ return {
339
+ kind: "error",
340
+ code: "reinvoke_required",
341
+ message: "Step-up authentication completed. Re-invoke the capability to continue.",
342
+ };
343
+ }
344
+ const browser = await browserContractFor(ctx, applicationId);
345
+ if (existing?.status === "pending") {
346
+ return {
347
+ kind: "auth_required",
348
+ elicitation: {
349
+ authUrl: existing.authUrl,
350
+ browser,
351
+ reason: existing.reason,
352
+ turnId: existing.turnId,
353
+ },
354
+ };
355
+ }
356
+ const turnId = randomUUID();
357
+ const callbackToken = randomUUID();
358
+ const authUrl = uiOrigin(ctx.config);
359
+ const turn = await ctx.turns.create({
360
+ authUrl,
361
+ callbackToken,
362
+ idempotencyKey,
363
+ reason: "idp_step_up",
364
+ ttlMs: ctx.config.turnTtlMs,
365
+ turnId,
366
+ });
367
+ ctx.onProgress?.({
368
+ message: `Authorize in browser: ${turn.authUrl}`,
369
+ type: "auth_required",
370
+ });
371
+ return {
372
+ kind: "auth_required",
373
+ elicitation: {
374
+ authUrl: turn.authUrl,
375
+ browser,
376
+ reason: "idp_step_up",
377
+ turnId: turn.turnId,
378
+ },
379
+ };
380
+ }
381
+ async function resolveApplicationId(ctx, principal, applicationId) {
382
+ if (applicationId?.trim()) {
383
+ return { kind: "ok", value: applicationId.trim() };
384
+ }
385
+ const recent = await ctx.recentApps.get({
386
+ organizationId: principal.organizationId,
387
+ userId: principal.userId,
388
+ });
389
+ if (!recent?.applicationId) {
390
+ return {
391
+ kind: "error",
392
+ code: "application_context_required",
393
+ message: APPLICATION_CONTEXT_REQUIRED,
394
+ };
395
+ }
396
+ return { kind: "ok", value: recent.applicationId };
397
+ }
398
+ async function rememberApplication(ctx, principal, applicationId) {
399
+ await ctx.recentApps.set({
400
+ applicationId,
401
+ organizationId: principal.organizationId,
402
+ userId: principal.userId,
403
+ });
404
+ }
405
+ function sleep(ms) {
406
+ return new Promise((resolve) => setTimeout(resolve, ms));
407
+ }
408
+ /**
409
+ * Sleeps between polls, and gives up the moment the caller does.
410
+ *
411
+ * A plain sleep here would have a cancelled call sit out the rest of the gap
412
+ * before anything looked at the signal, holding a timer on behalf of a
413
+ * request that nobody is waiting for any more. Both the timer and the
414
+ * listener are released on the way out, whichever of the two arrives first.
415
+ */
416
+ function sleepUntilAbort(ms, signal) {
417
+ if (signal === undefined) {
418
+ return sleep(ms);
419
+ }
420
+ if (signal.aborted) {
421
+ return Promise.resolve();
422
+ }
423
+ return new Promise((resolve) => {
424
+ const done = () => {
425
+ clearTimeout(timer);
426
+ signal.removeEventListener("abort", done);
427
+ resolve();
428
+ };
429
+ const timer = setTimeout(done, ms);
430
+ signal.addEventListener("abort", done, { once: true });
431
+ });
432
+ }
433
+ async function waitForDeployLive(ctx, input) {
434
+ const deadline = Date.now() + ctx.config.publishWaitTimeoutMs;
435
+ let lastStatus = "queued";
436
+ while (Date.now() < deadline) {
437
+ if (input.signal?.aborted) {
438
+ return {
439
+ kind: "error",
440
+ code: "publish_aborted",
441
+ message: "Publish wait was cancelled.",
442
+ };
443
+ }
444
+ let status;
445
+ try {
446
+ status = await ctx.server.getDeployStatus({
447
+ apiKey: input.apiKey,
448
+ applicationId: input.applicationId,
449
+ commitId: input.commitId,
450
+ });
451
+ }
452
+ catch (error) {
453
+ return {
454
+ kind: "error",
455
+ code: "publish_failed",
456
+ message: error instanceof Error ? error.message : String(error),
457
+ };
458
+ }
459
+ lastStatus = status.status;
460
+ if (status.status === "DEPLOYED") {
461
+ ctx.onProgress?.({ message: "App is live", type: "progress" });
462
+ return { kind: "ok", value: {} };
463
+ }
464
+ if (status.status === "FAILED") {
465
+ return {
466
+ kind: "error",
467
+ code: "publish_failed",
468
+ message: status.error ?? "The Superblocks API could not deploy the app.",
469
+ };
470
+ }
471
+ if (status.status === "AWAITING_ACK") {
472
+ return {
473
+ kind: "error",
474
+ code: "publish_needs_acknowledgement",
475
+ 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.`,
476
+ };
477
+ }
478
+ // "none" is not evidence of a successful deploy: the queue row may be
479
+ // missing, cleaned up after failure, or the deploy path may be unavailable.
480
+ // Keep waiting until DEPLOYED, FAILED, AWAITING_ACK, or timeout.
481
+ ctx.onProgress?.({
482
+ message: `Waiting for deploy (${status.status})…`,
483
+ type: "progress",
484
+ });
485
+ await sleep(ctx.config.publishPollIntervalMs);
486
+ }
487
+ return {
488
+ kind: "error",
489
+ code: "publish_timeout",
490
+ message: `Timed out waiting for the app to go live (last status: ${lastStatus}). Open ${publishUrl(ctx.config, input.applicationId)} to check progress.`,
491
+ };
492
+ }
493
+ /**
494
+ * Both ways to look at a finished app, in the summary rather than only in the
495
+ * server instructions — clients follow a tool's own output more closely, and a
496
+ * user who is never offered the preview deploys to their whole organization to
497
+ * see what they built.
498
+ */
499
+ const NEXT_STEPS = "Preview it with preview_app, or publish it with publish_app";
500
+ /**
501
+ * Closing progress line, so the client's last word says whether the app is
502
+ * committed and openable or still needs something from Claude.
503
+ */
504
+ function turnSummary(turn) {
505
+ if (turn.generationStatus === "needs_decision") {
506
+ return "Superblocks needs a decision — call edit_app with the answer";
507
+ }
508
+ if (turn.generationStatus === "no_changes") {
509
+ return "The Superblocks job ended without building anything — read its reply";
510
+ }
511
+ if (turn.generationStatus === "detached") {
512
+ return "Superblocks is still working, but only the editor can show it — open the edit URL";
513
+ }
514
+ if (turn.generationStatus === "live_edit_terminated") {
515
+ return "The Superblocks live-edit session terminated before anything was built — send the prompt again with edit_app";
516
+ }
517
+ if (turn.checkpointCommitId) {
518
+ return `Application ready — checkpoint saved. ${NEXT_STEPS}`;
519
+ }
520
+ if (turn.viewStatus === "ready") {
521
+ return `Application ready. ${NEXT_STEPS}`;
522
+ }
523
+ return "Superblocks finished generating; the app may still be building in the editor";
524
+ }
525
+ /**
526
+ * Combines local turn activity with durable Redis lines, oldest-first, without
527
+ * repeating the same message when both sources saw it.
528
+ */
529
+ function mergeActivity(live, persisted) {
530
+ const seen = new Set();
531
+ const merged = [];
532
+ for (const line of [...persisted, ...live]) {
533
+ if (!line || seen.has(line)) {
534
+ continue;
535
+ }
536
+ seen.add(line);
537
+ merged.push(line);
538
+ }
539
+ return merged;
540
+ }
541
+ async function promptInput(ctx, input) {
542
+ return {
543
+ apiKey: ctx.principal.apiKey,
544
+ applicationId: input.applicationId,
545
+ ...(input.attachments ? { attachments: input.attachments } : {}),
546
+ ...(input.importSource ? { importSource: input.importSource } : {}),
547
+ keepalive: async () => {
548
+ await ctx.server.connectLiveEdit({
549
+ apiKey: ctx.principal.apiKey,
550
+ applicationId: input.applicationId,
551
+ branch: input.branch,
552
+ });
553
+ },
554
+ ...(input.liveEditId ? { liveEditId: input.liveEditId } : {}),
555
+ liveEditUrl: input.liveEditUrl,
556
+ mode: input.mode ?? "PLAN",
557
+ // Asked for only if the host above turns out not to serve the session any
558
+ // more. Going back through openLiveEdit rather than connectLiveEdit
559
+ // directly keeps the resume pointer honest: a session that moved is a
560
+ // different stream, and a caller polling by application id has to be sent
561
+ // to the new one.
562
+ resolveLiveEdit: () => openLiveEdit(ctx, {
563
+ apiKey: ctx.principal.apiKey,
564
+ applicationId: input.applicationId,
565
+ ...(input.branch ? { branch: input.branch } : {}),
566
+ organizationId: input.organizationId,
567
+ }),
568
+ onProgress: (message) => {
569
+ ctx.onProgress?.({ message, type: "progress" });
570
+ },
571
+ organizationId: input.organizationId,
572
+ ...(input.planContext ? { planContext: input.planContext } : {}),
573
+ prompt: input.prompt,
574
+ ...(input.responseMetadata
575
+ ? { responseMetadata: input.responseMetadata }
576
+ : {}),
577
+ sessionToken: await ctx.server.issueScopedToken({
578
+ apiKey: ctx.principal.apiKey,
579
+ applicationId: input.applicationId,
580
+ }),
581
+ userId: input.userId,
582
+ };
583
+ }
584
+ /**
585
+ * Opens the live-edit session a prompt will run on, and records which one it
586
+ * is against the application.
587
+ *
588
+ * The pointer is what makes resume reachable at all. A caller polling after a
589
+ * restart, from another replica, or from a channel that never started the
590
+ * build has an application id and nothing else; the event streams are keyed by
591
+ * live edit. Without this, every such caller finds no stream to read and falls
592
+ * back to "unknown" forever.
593
+ *
594
+ * Written before the prompt rather than after it, because the events this
595
+ * points at start arriving as soon as the turn does. A failure to record it is
596
+ * swallowed: it costs resume, and failing the prompt over it would trade a
597
+ * degraded poll for no build at all.
598
+ */
599
+ async function openLiveEdit(ctx, input) {
600
+ const session = await ctx.server.connectLiveEdit({
601
+ apiKey: input.apiKey,
602
+ applicationId: input.applicationId,
603
+ branch: input.branch,
604
+ });
605
+ try {
606
+ await ctx.progressCursors?.setLiveEditId({
607
+ applicationId: input.applicationId,
608
+ liveEditId: session.liveEditId,
609
+ organizationId: input.organizationId,
610
+ });
611
+ }
612
+ catch (error) {
613
+ console.warn("gateway could not record the live edit for resume", {
614
+ message: error instanceof Error ? error.message : String(error),
615
+ });
616
+ }
617
+ return session;
618
+ }
619
+ /**
620
+ * Hands the prompt to Clark and returns as soon as it is accepted, leaving the
621
+ * turn running on the held session for `check_app_progress` to follow.
622
+ */
623
+ async function startClarkPrompt(ctx, input) {
624
+ const started = await ctx.sessionPeer.startPrompt(await promptInput(ctx, input));
625
+ if (started.status === "auth_required") {
626
+ return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, input.applicationId);
627
+ }
628
+ if (input.mode === "BUILD") {
629
+ ctx.sessionPeer.markBuildMode?.(input.applicationId);
630
+ }
631
+ return {
632
+ kind: "ok",
633
+ value: {
634
+ activity: started.activity,
635
+ // Carried through because the peer may have followed the session to a
636
+ // host this call never named, and the result has to send the caller to
637
+ // the live edit the turn is actually on.
638
+ ...(started.liveEditId ? { liveEditId: started.liveEditId } : {}),
639
+ },
640
+ };
641
+ }
642
+ async function forwardClarkPrompt(ctx, input) {
643
+ const forward = await ctx.sessionPeer.forwardPrompt(await promptInput(ctx, input));
644
+ if (forward.status === "auth_required") {
645
+ return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, input.applicationId);
646
+ }
647
+ if (input.mode === "BUILD") {
648
+ ctx.sessionPeer.markBuildMode?.(input.applicationId);
649
+ }
650
+ if (forward.generationStatus === "timeout") {
651
+ return {
652
+ kind: "error",
653
+ code: "superblocks_turn_timeout",
654
+ 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)}.`,
655
+ ...retryIfReplayable(input.attachments),
656
+ };
657
+ }
658
+ return {
659
+ kind: "ok",
660
+ value: {
661
+ ...(forward.checkpointCommitId
662
+ ? { checkpointCommitId: forward.checkpointCommitId }
663
+ : {}),
664
+ reply: forward.reply,
665
+ ...(forward.decision ? { decision: forward.decision } : {}),
666
+ generationStatus: forward.generationStatus,
667
+ ...(forward.liveEditId ? { liveEditId: forward.liveEditId } : {}),
668
+ ...(forward.recentRuns && forward.recentRuns.length > 0
669
+ ? { recentRuns: forward.recentRuns }
670
+ : {}),
671
+ viewStatus: forward.viewStatus,
672
+ },
673
+ };
674
+ }
675
+ /**
676
+ * Waits between create attempts after a dev-env rejection. Short enough that
677
+ * `start_app` still returns in the seconds its callers expect.
678
+ */
679
+ const DEV_ENV_RETRY_DELAYS_MS = [400, 1200];
680
+ /**
681
+ * Whether a failed create is worth repeating.
682
+ *
683
+ * The server gates code-mode apps on a LaunchDarkly flag. A server that can tell
684
+ * an unreadable flag from one that is off answers 503 and means "ask again". An
685
+ * older one answers 400 "Dev Env is not enabled" for both, because the SDK hands
686
+ * back the gate's `false` default whenever its stream is down without saying so —
687
+ * which locally, where the stream drops constantly, greeted most first prompts.
688
+ */
689
+ function isRetryableCreateFailure(error) {
690
+ if (!(error instanceof ServerHttpError)) {
691
+ return false;
692
+ }
693
+ return (error.status === 503 ||
694
+ (error.status === 400 && /dev env is not enabled/i.test(error.message)));
695
+ }
696
+ /**
697
+ * Creates the application, absorbing a dev-env rejection that is really a
698
+ * momentarily unreadable feature flag. Retrying here keeps the flap away from
699
+ * the user; only a rejection that outlives every attempt reaches them, and it
700
+ * says the flag read failed rather than blaming their account.
701
+ */
702
+ async function createApplicationForPrompt(ctx, args) {
703
+ for (let attempt = 0; attempt <= DEV_ENV_RETRY_DELAYS_MS.length; attempt += 1) {
704
+ try {
705
+ return { kind: "ok", value: await ctx.server.createApplication(args) };
706
+ }
707
+ catch (error) {
708
+ if (!isRetryableCreateFailure(error)) {
709
+ throw error;
710
+ }
711
+ const delayMs = DEV_ENV_RETRY_DELAYS_MS[attempt];
712
+ if (delayMs !== undefined) {
713
+ await sleep(delayMs);
714
+ }
715
+ }
716
+ }
717
+ return {
718
+ kind: "error",
719
+ code: "dev_env_unavailable",
720
+ 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.`,
721
+ };
722
+ }
723
+ /**
724
+ * Names the application a failed call was working on.
725
+ *
726
+ * Every failure past the point where an application exists has to carry its id,
727
+ * because that id is the caller's only handle on a resource the call already
728
+ * persisted. `start_app` creates the row before it reaches live edit, so a
729
+ * connect that times out leaves an app the caller owns and never saw; and every
730
+ * app-scoped entry point may have resolved the id from the recent-app default
731
+ * rather than from its arguments, so the failure is the one place a caller can
732
+ * learn which app it actually drove.
733
+ */
734
+ function namingApplication(applicationId, result) {
735
+ return result.kind === "error" ? { ...result, applicationId } : result;
736
+ }
737
+ /**
738
+ * Names the application `start_app` or `import_app` created, and says on the
739
+ * way out that it is still there.
740
+ *
741
+ * These two are the entry points that persist an application before the build
742
+ * can start, so their failures are the ones that leave a resource behind. Until
743
+ * every caller reads `applicationId` and retries rather than starting over,
744
+ * this line is how an operator finds the applications they abandoned.
745
+ */
746
+ function namingCreatedApplication(applicationId, capability, result) {
747
+ if (result.kind === "error") {
748
+ console.warn("gateway left an application behind", {
749
+ applicationId,
750
+ capability,
751
+ code: result.code,
752
+ });
753
+ }
754
+ return namingApplication(applicationId, result);
755
+ }
756
+ /**
757
+ * The recovery to advertise when the machinery around a turn fails: `edit_app`
758
+ * with the same prompt, which is what `retry_build` means.
759
+ *
760
+ * Only for a prompt `edit_app` could actually replay. A prompt carrying
761
+ * attachments is not one: `edit_app` has no way to resupply an attachment, so
762
+ * an import's migration prompt would reach Superblocks describing an archive it
763
+ * cannot see. Advertising a recovery that cannot finish is worse than
764
+ * advertising none, so the import path gets `applicationId` and no hint.
765
+ * TODO(ENG-5917): let `import_app` resume into an application it already
766
+ * created, and give the import path a recovery of its own.
767
+ */
768
+ function retryIfReplayable(attachments) {
769
+ return attachments && attachments.length > 0
770
+ ? undefined
771
+ : { pendingAction: "retry_build" };
772
+ }
773
+ /**
774
+ * A live edit that would not open. The app itself is intact and Clark never ran,
775
+ * so where the prompt can be replayed the caller's way out is to send it again
776
+ * rather than start over on a second app.
777
+ */
778
+ function liveEditUnavailable(error, attachments) {
779
+ return {
780
+ kind: "error",
781
+ code: "live_edit_unavailable",
782
+ message: error instanceof Error ? error.message : String(error),
783
+ ...retryIfReplayable(attachments),
784
+ };
785
+ }
786
+ /**
787
+ * Tell the peer that a turn for this application died before Superblocks ever
788
+ * saw it.
789
+ *
790
+ * Nothing else holds that: no turn was ever started, so without this the gateway
791
+ * forgets the attempt entirely and every later read answers "nothing in flight"
792
+ * - `check_app_progress` reports `unknown` while `get_app` commits and previews
793
+ * the untouched starter template as though a build were running (ENG-5808).
794
+ *
795
+ * Deliberately not called when the prompt itself failed on an open session. The
796
+ * prompt may well have reached Superblocks and be running now, and an
797
+ * unanswered acknowledgement does not say which; telling the caller "nothing is
798
+ * running, send it again" would be the same overclaim this fixes, and acting on
799
+ * it can build the app twice.
800
+ */
801
+ function rememberFailedStart(ctx, input) {
802
+ if (refusedCaller(input.error)) {
803
+ return;
804
+ }
805
+ const cause = input.error instanceof Error ? input.error.message : String(input.error);
806
+ try {
807
+ ctx.sessionPeer.recordFailedStart?.({
808
+ applicationId: input.applicationId,
809
+ organizationId: input.principal.organizationId,
810
+ // Reaches the caller as the dead start's reply on its next poll, so it
811
+ // has to stand on its own there rather than lean on this call's error.
812
+ reason: `The Superblocks live-edit session for application ${input.applicationId} never opened, so the prompt never reached Superblocks and nothing was built. Nothing is running for that application; send the same instruction again to retry it. (${cause})`,
813
+ userId: input.principal.userId,
814
+ });
815
+ }
816
+ catch (error) {
817
+ // Swallowed: the failure the caller is actually being told about must not be
818
+ // replaced by a failure to write it down. Logged with the principal it was
819
+ // for, because the symptom is the one this record exists to prevent.
820
+ console.warn("gateway failed-start record on the session peer failed", {
821
+ applicationId: input.applicationId,
822
+ message: error instanceof Error ? error.message : String(error),
823
+ organizationId: input.principal.organizationId,
824
+ userId: input.principal.userId,
825
+ });
826
+ }
827
+ }
828
+ /**
829
+ * Whether Superblocks turned this caller away rather than failing to answer.
830
+ *
831
+ * A refusal is about the caller, not about the application. `connectLiveEdit`
832
+ * is where Superblocks authorizes them and it authorizes per application, while
833
+ * `resolveApplicationId` takes whatever application id it is handed - so
834
+ * recording a refusal would let anyone naming an app they cannot open author the
835
+ * status read by the people who can, and occupy a slot in a bounded store by
836
+ * naming one.
837
+ *
838
+ * 404 counts. Superblocks' existence checks are organization scoped, so an
839
+ * application belonging to another tenant reads as missing rather than as
840
+ * forbidden - and a caller who cannot see an app has no business authoring a
841
+ * status for it. A genuinely missing app is the same answer for the same reason.
842
+ */
843
+ function refusedCaller(error) {
844
+ return (error instanceof ServerHttpError &&
845
+ (error.status === 401 || error.status === 403 || error.status === 404));
846
+ }
847
+ /**
848
+ * The first prompt on an application that was just created, for both
849
+ * `start_app` and `import_app`: connect a live-edit session, hand Clark the
850
+ * prompt, and shape the turn it ran.
851
+ */
852
+ async function promptNewApplication(ctx, args) {
853
+ const { applicationId, input, principal, prompt } = args;
854
+ ctx.onProgress?.({
855
+ message: "Connecting live-edit session",
856
+ type: "progress",
857
+ });
858
+ let session;
859
+ try {
860
+ session = await openLiveEdit(ctx, {
861
+ apiKey: principal.apiKey,
862
+ applicationId,
863
+ branch: input.branch,
864
+ organizationId: principal.organizationId,
865
+ });
866
+ }
867
+ catch (error) {
868
+ rememberFailedStart(ctx, { applicationId, error, principal });
869
+ return liveEditUnavailable(error, args.attachments);
870
+ }
871
+ ctx.onProgress?.({
872
+ message: args.importSource
873
+ ? "Forwarding import prompt to Superblocks"
874
+ : "Forwarding prompt to Superblocks",
875
+ type: "progress",
876
+ });
877
+ const promptArgs = {
878
+ applicationId,
879
+ ...(args.attachments ? { attachments: args.attachments } : {}),
880
+ branch: input.branch,
881
+ idempotencyKey: input.idempotencyKey,
882
+ ...(args.importSource ? { importSource: args.importSource } : {}),
883
+ liveEditId: session.liveEditId,
884
+ liveEditUrl: session.liveEditUrl,
885
+ mode: input.mode ?? "PLAN",
886
+ organizationId: principal.organizationId,
887
+ prompt,
888
+ userId: principal.userId,
889
+ };
890
+ const named = () => finalAppName(ctx, {
891
+ apiKey: principal.apiKey,
892
+ applicationId,
893
+ explicitName: args.explicitName,
894
+ name: args.name,
895
+ prompt,
896
+ });
897
+ if (input.wait === "ack") {
898
+ const started = await startClarkPrompt(ctx, promptArgs);
899
+ if (started.kind !== "ok") {
900
+ return started;
901
+ }
902
+ return {
903
+ kind: "ok",
904
+ value: await withBrowser(ctx, applicationId, {
905
+ activity: started.value.activity,
906
+ applicationId,
907
+ editUrl: editUrl(ctx.config, applicationId),
908
+ liveEditId: started.value.liveEditId ?? session.liveEditId,
909
+ mode: promptArgs.mode,
910
+ name: await named(),
911
+ status: "building",
912
+ }),
913
+ };
914
+ }
915
+ const forward = await forwardClarkPrompt(ctx, promptArgs);
916
+ if (forward.kind !== "ok") {
917
+ return forward;
918
+ }
919
+ const result = {
920
+ applicationId,
921
+ ...(forward.value.checkpointCommitId
922
+ ? { checkpointCommitId: forward.value.checkpointCommitId }
923
+ : {}),
924
+ reply: forward.value.reply,
925
+ ...(forward.value.decision ? { decision: forward.value.decision } : {}),
926
+ editUrl: editUrl(ctx.config, applicationId),
927
+ generationStatus: forward.value.generationStatus,
928
+ liveEditId: forward.value.liveEditId ?? session.liveEditId,
929
+ mode: promptArgs.mode,
930
+ name: await named(),
931
+ ...(forward.value.recentRuns && forward.value.recentRuns.length > 0
932
+ ? { recentRuns: forward.value.recentRuns }
933
+ : {}),
934
+ status: buildStatusOf(forward.value.generationStatus),
935
+ viewStatus: forward.value.viewStatus,
936
+ };
937
+ ctx.onProgress?.({
938
+ message: turnSummary(forward.value),
939
+ type: "completed",
940
+ });
941
+ return { kind: "ok", value: await withBrowser(ctx, applicationId, result) };
942
+ }
943
+ export async function startApp(ctx, input) {
944
+ const principalResult = await ensurePrincipal(ctx);
945
+ if (principalResult.kind !== "ok") {
946
+ return principalResult;
947
+ }
948
+ const principal = principalResult.value;
949
+ const explicitName = input.name?.trim();
950
+ const name = explicitName || defaultAppName(input.prompt);
951
+ ctx.onProgress?.({ message: "Creating application", type: "progress" });
952
+ const created = await createApplicationForPrompt(ctx, {
953
+ apiKey: principal.apiKey,
954
+ name,
955
+ organizationId: principal.organizationId,
956
+ });
957
+ if (created.kind !== "ok") {
958
+ return created;
959
+ }
960
+ const app = created.value;
961
+ // The row is persisted from here on, so it is this caller's most recent app
962
+ // whether or not the turn ever runs. Waiting for the turn to be accepted left
963
+ // the recent-app default pointing at the previous app, and a caller retrying
964
+ // after a failure drove that one instead of the app it had just created.
965
+ await rememberApplication(ctx, principal, app.id);
966
+ return namingCreatedApplication(app.id, "start_app", await promptNewApplication(ctx, {
967
+ applicationId: app.id,
968
+ explicitName,
969
+ input,
970
+ name,
971
+ principal,
972
+ prompt: input.prompt,
973
+ }));
974
+ }
975
+ /** Resolves symlinks where possible so containment is checked on real paths. */
976
+ async function realPath(candidate) {
977
+ try {
978
+ return await realpath(candidate);
979
+ }
980
+ catch {
981
+ return path.resolve(candidate);
982
+ }
983
+ }
984
+ /**
985
+ * Reads a candidate path only when it truly sits in one of the configured
986
+ * search directories.
987
+ *
988
+ * The caller is remote and holds nothing but a token, so a path it names must
989
+ * never widen what the gateway will read. Comparison happens after resolving
990
+ * symlinks on both sides: a link planted in a search directory is otherwise
991
+ * enough to walk straight back out of it, and on macOS the directories
992
+ * themselves (`/tmp`) are often links. The resolved path is also what gets
993
+ * read, so re-pointing the link afterwards changes nothing.
994
+ */
995
+ async function readWithinSearchDirs(candidate, searchDirs) {
996
+ const file = await realPath(candidate);
997
+ for (const dir of searchDirs) {
998
+ const root = await realPath(dir);
999
+ // The separator matters: `~/downloads-evil` is not inside `~/downloads`.
1000
+ if (file !== root && !file.startsWith(root + path.sep)) {
1001
+ continue;
1002
+ }
1003
+ try {
1004
+ const info = await stat(file);
1005
+ if (!info.isFile()) {
1006
+ return undefined;
1007
+ }
1008
+ // Sizing from the file rather than from bytes already read keeps a caller
1009
+ // from spending the gateway's heap on a file it merely named.
1010
+ if (info.size > IMPORT_ZIP_MAX_BYTES) {
1011
+ return { oversizeBytes: info.size };
1012
+ }
1013
+ return { bytes: await readFile(file) };
1014
+ }
1015
+ catch {
1016
+ return undefined;
1017
+ }
1018
+ }
1019
+ return undefined;
1020
+ }
1021
+ /**
1022
+ * Reads the archive by name from one of the configured search directories.
1023
+ *
1024
+ * Only the file name of `zipPath` is used. The directory part is thrown away
1025
+ * before joining, so neither a sandbox path the model quotes nor a `../` in it
1026
+ * can reach a file the operator did not put in one of these folders.
1027
+ */
1028
+ async function readFromSearchDirs(fileName, searchDirs) {
1029
+ for (const dir of searchDirs) {
1030
+ const found = await readWithinSearchDirs(path.join(dir, fileName), [dir]);
1031
+ if (found) {
1032
+ return found;
1033
+ }
1034
+ }
1035
+ return undefined;
1036
+ }
1037
+ function tooLargeError(size) {
1038
+ return {
1039
+ kind: "error",
1040
+ code: "import_zip_too_large",
1041
+ message: `Archive is ${size} bytes; the gateway import limit is ${IMPORT_ZIP_MAX_BYTES} bytes (1 MB). Larger archives almost certainly include node_modules or other install artifacts — re-export without those; dependencies are installed on or after import.`,
1042
+ };
1043
+ }
1044
+ /**
1045
+ * Reads the ZIP (or tarball) from a host path or base64 payload and enforces
1046
+ * the gateway's 1 MB cap before any Superblocks API call.
1047
+ */
1048
+ async function resolveImportArchive(input, searchDirs) {
1049
+ const hasPath = Boolean(input.zipPath?.trim());
1050
+ const hasBase64 = Boolean(input.zipBase64?.trim());
1051
+ if (hasPath === hasBase64) {
1052
+ return {
1053
+ kind: "error",
1054
+ code: "import_zip_required",
1055
+ message: "Provide exactly one of zipPath (a path or file name on the gateway host) or zipBase64.",
1056
+ };
1057
+ }
1058
+ let bytes;
1059
+ let fileName;
1060
+ if (hasPath) {
1061
+ const zipPath = input.zipPath.trim();
1062
+ fileName = path.basename(zipPath);
1063
+ let read;
1064
+ if (path.isAbsolute(zipPath)) {
1065
+ // A path the caller can see is often one this host cannot — a chat
1066
+ // attachment lives in the model's sandbox — and a path this host can see
1067
+ // is only read when it is in a folder the operator opened up.
1068
+ read = await readWithinSearchDirs(zipPath, searchDirs);
1069
+ }
1070
+ read ??= await readFromSearchDirs(fileName, searchDirs);
1071
+ if (read && "oversizeBytes" in read) {
1072
+ return tooLargeError(read.oversizeBytes);
1073
+ }
1074
+ if (!read) {
1075
+ return {
1076
+ kind: "error",
1077
+ code: "import_zip_unreadable",
1078
+ message: `No file named "${fileName}" that this gateway will read: "${zipPath}" is not in ${searchDirs.join(" or ")}, and those are the only folders it opens archives from. Ask the user to move the archive into one of them, or to say where it is so the operator can allow that folder. Do not encode the file as base64 — a real archive takes minutes to write out and arrives corrupted.`,
1079
+ };
1080
+ }
1081
+ bytes = read.bytes;
1082
+ }
1083
+ else {
1084
+ try {
1085
+ bytes = Buffer.from(input.zipBase64.trim(), "base64");
1086
+ }
1087
+ catch {
1088
+ return {
1089
+ kind: "error",
1090
+ code: "import_zip_invalid",
1091
+ message: "zipBase64 is not valid base64.",
1092
+ };
1093
+ }
1094
+ if (bytes.byteLength === 0) {
1095
+ return {
1096
+ kind: "error",
1097
+ code: "import_zip_invalid",
1098
+ message: "zipBase64 decoded to an empty payload.",
1099
+ };
1100
+ }
1101
+ fileName = "import.zip";
1102
+ }
1103
+ if (bytes.byteLength > IMPORT_ZIP_MAX_BYTES) {
1104
+ return tooLargeError(bytes.byteLength);
1105
+ }
1106
+ const archiveType = detectAttachmentArchiveType({ fileName });
1107
+ if (archiveType === null) {
1108
+ return {
1109
+ kind: "error",
1110
+ code: "import_zip_not_archive",
1111
+ message: `Expected a .zip, .tgz, or .gz archive; got "${fileName}".`,
1112
+ };
1113
+ }
1114
+ return {
1115
+ kind: "ok",
1116
+ value: {
1117
+ bytes,
1118
+ fileName,
1119
+ mimeType: mimeTypeForArchiveType(archiveType),
1120
+ },
1121
+ };
1122
+ }
1123
+ function mimeTypeForArchiveType(archiveType) {
1124
+ switch (archiveType) {
1125
+ case "gz":
1126
+ case "tgz":
1127
+ return "application/gzip";
1128
+ case "zip":
1129
+ return "application/zip";
1130
+ }
1131
+ }
1132
+ function normalizeImportSource(source) {
1133
+ switch (source) {
1134
+ case "chatgpt":
1135
+ case "claude":
1136
+ case "claude-design":
1137
+ case "lovable":
1138
+ case "replit":
1139
+ case "streamlit":
1140
+ case "v0":
1141
+ case "zip":
1142
+ return source;
1143
+ default:
1144
+ return "zip";
1145
+ }
1146
+ }
1147
+ /**
1148
+ * Creates a fullstack app, uploads the caller's archive as an app attachment,
1149
+ * and hands Clark a migration prompt — the same path as the browser import
1150
+ * wizard, without the sessionStorage hop.
1151
+ */
1152
+ export async function importApp(ctx, input) {
1153
+ const archiveResult = await resolveImportArchive(input, ctx.config.importSearchDirs);
1154
+ if (archiveResult.kind !== "ok") {
1155
+ return archiveResult;
1156
+ }
1157
+ const archive = archiveResult.value;
1158
+ const principalResult = await ensurePrincipal(ctx);
1159
+ if (principalResult.kind !== "ok") {
1160
+ return principalResult;
1161
+ }
1162
+ const principal = principalResult.value;
1163
+ const source = normalizeImportSource(input.source);
1164
+ const migrationPrompt = buildImportPrompt({
1165
+ extraPrompt: input.prompt,
1166
+ source,
1167
+ zipFileName: archive.fileName,
1168
+ });
1169
+ const explicitName = input.name?.trim();
1170
+ const name = explicitName || defaultAppName(migrationPrompt);
1171
+ ctx.onProgress?.({
1172
+ message: "Creating application for import",
1173
+ type: "progress",
1174
+ });
1175
+ const created = await createApplicationForPrompt(ctx, {
1176
+ apiKey: principal.apiKey,
1177
+ name,
1178
+ organizationId: principal.organizationId,
1179
+ templateName: "app-fullstack",
1180
+ });
1181
+ if (created.kind !== "ok") {
1182
+ return created;
1183
+ }
1184
+ const app = created.value;
1185
+ // See startApp: the row outlives any failure below it, so it is already this
1186
+ // caller's most recent app.
1187
+ await rememberApplication(ctx, principal, app.id);
1188
+ return namingCreatedApplication(app.id, "import_app", await migrateArchiveIntoApplication(ctx, {
1189
+ applicationId: app.id,
1190
+ archive,
1191
+ explicitName,
1192
+ input,
1193
+ migrationPrompt,
1194
+ name,
1195
+ principal,
1196
+ source,
1197
+ }));
1198
+ }
1199
+ /**
1200
+ * Uploads the archive onto an application that already exists and hands
1201
+ * Superblocks the migration prompt that reads it.
1202
+ */
1203
+ async function migrateArchiveIntoApplication(ctx, args) {
1204
+ const { applicationId, archive, principal } = args;
1205
+ ctx.onProgress?.({
1206
+ message: "Uploading archive attachment",
1207
+ type: "progress",
1208
+ });
1209
+ let upload;
1210
+ try {
1211
+ upload = await ctx.server.uploadAttachment({
1212
+ apiKey: principal.apiKey,
1213
+ applicationId,
1214
+ bytes: archive.bytes,
1215
+ fileName: archive.fileName,
1216
+ mimeType: archive.mimeType,
1217
+ });
1218
+ }
1219
+ catch (error) {
1220
+ const message = error instanceof Error ? error.message : String(error);
1221
+ return {
1222
+ kind: "error",
1223
+ code: "import_upload_failed",
1224
+ message,
1225
+ };
1226
+ }
1227
+ return promptNewApplication(ctx, {
1228
+ applicationId,
1229
+ attachments: [
1230
+ {
1231
+ applicationId,
1232
+ fileName: upload.fileName,
1233
+ mediaType: upload.mimeType ?? archive.mimeType,
1234
+ scopeType: "app",
1235
+ signedUrl: upload.signedUrl,
1236
+ signedUrlExpiresAt: upload.signedUrlExpiresAt,
1237
+ storageKey: upload.storageKey,
1238
+ type: "uploaded",
1239
+ url: upload.contentUrl,
1240
+ },
1241
+ ],
1242
+ explicitName: args.explicitName,
1243
+ importSource: args.source,
1244
+ input: args.input,
1245
+ name: args.name,
1246
+ principal,
1247
+ prompt: args.migrationPrompt,
1248
+ });
1249
+ }
1250
+ /** Machine-readable shape a cancelled wait answers with. */
1251
+ function cancelled(capability) {
1252
+ return {
1253
+ kind: "error",
1254
+ code: "cancelled",
1255
+ message: `${capability} was cancelled by the caller.`,
1256
+ };
1257
+ }
1258
+ function nowMs(ctx) {
1259
+ return (ctx.now ?? Date.now)();
1260
+ }
1261
+ /**
1262
+ * The longest any single host-facing call may block.
1263
+ *
1264
+ * Independent of whether the host supports progress notifications, and
1265
+ * deliberately so: a progress notification is a courtesy the host may ignore,
1266
+ * and a call that outlives the host's tool-call ceiling is a hung request
1267
+ * whether or not notifications were sent along the way. Every wait in this
1268
+ * file is clamped to it, and a call that hits the clamp answers with pacing
1269
+ * and a cursor instead of holding the line open.
1270
+ */
1271
+ function hostCallBudgetMs(ctx, requestedMs) {
1272
+ return Math.max(0, Math.min(requestedMs, ctx.config.hostCallCeilingMs));
1273
+ }
1274
+ /** Where a caller's cursor for one application lives. */
1275
+ function cursorKeyFor(ctx, principal, applicationId) {
1276
+ if (!ctx.caller || !ctx.progressCursors) {
1277
+ return undefined;
1278
+ }
1279
+ return {
1280
+ applicationId,
1281
+ caller: ctx.caller,
1282
+ organizationId: principal.organizationId,
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Reads the durable stream for this caller without consuming it.
1287
+ *
1288
+ * Returns nothing when resume is not wired (no store, or no live edit known
1289
+ * for this application yet), which is what keeps one-shot callers and tests
1290
+ * of the live path on exactly the behavior they had.
1291
+ */
1292
+ async function readSnapshot(ctx, input) {
1293
+ const store = ctx.events;
1294
+ if (!store || !ctx.progressCursors) {
1295
+ return undefined;
1296
+ }
1297
+ const liveEditId = await ctx.progressCursors.getLiveEditId({
1298
+ applicationId: input.applicationId,
1299
+ organizationId: input.organizationId,
1300
+ });
1301
+ if (!liveEditId) {
1302
+ return undefined;
1303
+ }
1304
+ const scope = {
1305
+ applicationId: input.applicationId,
1306
+ liveEditId,
1307
+ organizationId: input.organizationId,
1308
+ };
1309
+ // A cursor a caller handed back gets the same validation a stored one does.
1310
+ // Anything unreadable falls back to that caller's own stored position: a
1311
+ // position this contract cannot vouch for would otherwise skip the caller
1312
+ // past unread events, and a corrupt one compares false against every
1313
+ // sequence and hides the whole stream for as long as the caller keeps
1314
+ // handing it back.
1315
+ const supplied = input.overrideCursor === undefined
1316
+ ? undefined
1317
+ : parseEventCursor(input.overrideCursor);
1318
+ return await store.snapshot({
1319
+ ...scope,
1320
+ cursor: supplied ?? input.cursor?.cursor,
1321
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
1322
+ });
1323
+ }
1324
+ export async function checkAppProgress(ctx, input) {
1325
+ const principalResult = await ensurePrincipal(ctx);
1326
+ if (principalResult.kind !== "ok") {
1327
+ return principalResult;
1328
+ }
1329
+ const principal = principalResult.value;
1330
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
1331
+ if (resolved.kind !== "ok") {
1332
+ return resolved;
1333
+ }
1334
+ const applicationId = resolved.value;
1335
+ // Checked before the wait as well as after it. A caller that cancelled
1336
+ // while the credential was being resolved should never reach the point of
1337
+ // registering a waiter it will not read.
1338
+ if (ctx.signal?.aborted) {
1339
+ return cancelled("check_app_progress");
1340
+ }
1341
+ const cursorKey = cursorKeyFor(ctx, principal, applicationId);
1342
+ const stored = cursorKey
1343
+ ? await ctx.progressCursors?.getCursor(cursorKey)
1344
+ : undefined;
1345
+ const update = await ctx.sessionPeer.awaitTurn({
1346
+ applicationId,
1347
+ organizationId: principal.organizationId,
1348
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
1349
+ userId: principal.userId,
1350
+ waitMs: hostCallBudgetMs(ctx, ctx.config.clarkProgressWaitMs),
1351
+ });
1352
+ if (ctx.signal?.aborted) {
1353
+ return cancelled("check_app_progress");
1354
+ }
1355
+ const snapshot = await readSnapshot(ctx, {
1356
+ applicationId,
1357
+ cursor: stored,
1358
+ organizationId: principal.organizationId,
1359
+ ...(input.cursor ? { overrideCursor: input.cursor } : {}),
1360
+ });
1361
+ // The local turn is an accelerator, not the record. When this process is
1362
+ // not holding the turn (restart, another replica), the persisted stream is
1363
+ // all there is. When it is holding a still-running turn, Redis may still
1364
+ // carry durable progress from another path that the local collector missed
1365
+ // — merge that activity so a quiet local turn does not look hung.
1366
+ const streamProgress = snapshot
1367
+ ? readPersistedProgress(snapshot, streamLiveness(ctx), ctx.config.clarkSettleMs)
1368
+ : undefined;
1369
+ const resumeFromStream = update.status === "unknown" ? streamProgress : undefined;
1370
+ const elapsedSeconds = Math.round(update.elapsedMs / 1000);
1371
+ const liveActivity = update.status === "running" && update.activity.length === 0
1372
+ ? []
1373
+ : update.activity;
1374
+ const mergedWhileRunning = update.status === "running" && streamProgress
1375
+ ? mergeActivity(liveActivity, streamProgress.activity)
1376
+ : undefined;
1377
+ const activity = resumeFromStream
1378
+ ? resumeFromStream.activity
1379
+ : mergedWhileRunning && mergedWhileRunning.length > 0
1380
+ ? mergedWhileRunning
1381
+ : update.status === "running" && update.activity.length === 0
1382
+ ? [`Superblocks is still generating (${elapsedSeconds}s)`]
1383
+ : liveActivity;
1384
+ // Live lines were already shown on earlier polls for this turn. When Redis
1385
+ // actually contributed durable lines, only stream those that are new relative
1386
+ // to live so the same progress is not re-emitted. An empty/lagging store must
1387
+ // still notify the live activity — otherwise a quiet Redis snapshot silences
1388
+ // the turn.
1389
+ const progressMessages = mergedWhileRunning !== undefined &&
1390
+ (streamProgress?.activity.length ?? 0) > 0
1391
+ ? mergedWhileRunning.filter((line) => !liveActivity.includes(line))
1392
+ : activity;
1393
+ for (const message of progressMessages) {
1394
+ ctx.onProgress?.({ message, type: "progress" });
1395
+ }
1396
+ const status = resumeFromStream
1397
+ ? resumeFromStream.status
1398
+ : update.status === "finished"
1399
+ ? buildStatusOf(update.generationStatus)
1400
+ : update.status === "running"
1401
+ ? "building"
1402
+ : "unknown";
1403
+ const terminal = status !== "building";
1404
+ const pacing = await recordPacing(ctx, {
1405
+ applicationId,
1406
+ appScope: { applicationId, organizationId: principal.organizationId },
1407
+ cursorKey,
1408
+ elapsedMs: update.elapsedMs,
1409
+ // Only advance the Redis cursor when this poll actually answered from
1410
+ // persisted events. Advancing while answering from the live socket alone
1411
+ // skips unread durable events after a restart.
1412
+ advanceCursor: resumeFromStream !== undefined ||
1413
+ (update.status === "running" &&
1414
+ (streamProgress?.activity.length ?? 0) > 0),
1415
+ // What Superblocks itself said, not what this poll is returning. The
1416
+ // heartbeat below is the gateway's own words, added so a quiet poll has
1417
+ // something to show; counting it as news would mean a build can never be
1418
+ // detected as quiet, because every quiet poll manufactures a line.
1419
+ saidSomething: resumeFromStream
1420
+ ? resumeFromStream.activity.length > 0
1421
+ : update.activity.length > 0 ||
1422
+ (mergedWhileRunning !== undefined && mergedWhileRunning.length > 0),
1423
+ snapshot,
1424
+ stored,
1425
+ terminal,
1426
+ // Only the local turn carries a clock. A build read out of the persisted
1427
+ // stream is running somewhere else and this process cannot say how long
1428
+ // for, so it reports no budget rather than inventing a full one.
1429
+ turnRunning: update.status === "running",
1430
+ });
1431
+ const base = {
1432
+ ...pacing.pacing,
1433
+ activity,
1434
+ applicationId,
1435
+ editUrl: editUrl(ctx.config, applicationId),
1436
+ elapsedSeconds,
1437
+ ...(pacing.stallHint ? { stallHint: pacing.stallHint } : {}),
1438
+ ...("recentRuns" in update &&
1439
+ Array.isArray(update.recentRuns) &&
1440
+ update.recentRuns.length > 0
1441
+ ? { recentRuns: update.recentRuns }
1442
+ : {}),
1443
+ };
1444
+ if (resumeFromStream) {
1445
+ return {
1446
+ kind: "ok",
1447
+ value: {
1448
+ ...base,
1449
+ ...(resumeFromStream.generationStatus
1450
+ ? { generationStatus: resumeFromStream.generationStatus }
1451
+ : {}),
1452
+ ...(resumeFromStream.reply ? { reply: resumeFromStream.reply } : {}),
1453
+ status,
1454
+ },
1455
+ };
1456
+ }
1457
+ if (update.status !== "finished") {
1458
+ return { kind: "ok", value: { ...base, status } };
1459
+ }
1460
+ ctx.onProgress?.({ message: turnSummary(update), type: "completed" });
1461
+ return {
1462
+ kind: "ok",
1463
+ value: {
1464
+ ...base,
1465
+ ...(update.checkpointCommitId
1466
+ ? { checkpointCommitId: update.checkpointCommitId }
1467
+ : {}),
1468
+ reply: update.reply,
1469
+ ...(update.decision ? { decision: update.decision } : {}),
1470
+ generationStatus: update.generationStatus,
1471
+ status,
1472
+ viewStatus: update.viewStatus,
1473
+ },
1474
+ };
1475
+ }
1476
+ /**
1477
+ * When this build last said anything, as far as this turn is concerned.
1478
+ *
1479
+ * Read app-scoped rather than per-caller so two readers of one build cannot
1480
+ * disagree about whether it has gone quiet, and never earlier than the turn
1481
+ * started: the stored value outlives the turn that wrote it, so the gap
1482
+ * between two turns — a user reading a plan before approving it — would
1483
+ * otherwise be counted as this turn having gone quiet.
1484
+ *
1485
+ * A store that cannot answer costs a stall hint, not the poll, so it reads as
1486
+ * "nothing heard since this turn began".
1487
+ */
1488
+ async function lastActivityWithinTurn(ctx, input) {
1489
+ try {
1490
+ const observedAtMs = await ctx.progressCursors?.getLastActivityAtMs(input.appScope);
1491
+ return Math.max(observedAtMs ?? input.turnStartedAtMs, input.turnStartedAtMs);
1492
+ }
1493
+ catch (error) {
1494
+ console.warn("gateway could not read when the build last spoke", {
1495
+ message: error instanceof Error ? error.message : String(error),
1496
+ });
1497
+ return input.turnStartedAtMs;
1498
+ }
1499
+ }
1500
+ /**
1501
+ * Records when the build last spoke, for every reader of it rather than the
1502
+ * one that happened to hear it.
1503
+ *
1504
+ * Swallowed on failure for the same reason the live-edit pointer is: it costs
1505
+ * a stall hint, and failing a progress poll over stall bookkeeping would trade
1506
+ * a degraded answer for no answer at all.
1507
+ */
1508
+ async function rememberLastActivity(ctx, input) {
1509
+ try {
1510
+ await ctx.progressCursors?.setLastActivityAtMs(input);
1511
+ }
1512
+ catch (error) {
1513
+ console.warn("gateway could not record when the build last spoke", {
1514
+ message: error instanceof Error ? error.message : String(error),
1515
+ });
1516
+ }
1517
+ }
1518
+ /**
1519
+ * Works out what to tell the caller about coming back, and persists its new
1520
+ * position.
1521
+ *
1522
+ * The cursor is written only here, and only after the response it describes
1523
+ * has been assembled: advancing it earlier would lose events for a call that
1524
+ * then failed. It advances only this caller's key, so a second reader of the
1525
+ * same build is unaffected.
1526
+ */
1527
+ async function recordPacing(ctx, input) {
1528
+ const quiet = !input.saidSomething && !input.terminal;
1529
+ const currentTimeMs = nowMs(ctx);
1530
+ const turnStartedAtMs = currentTimeMs - input.elapsedMs;
1531
+ const lastActivityAtMs = input.saidSomething
1532
+ ? currentTimeMs
1533
+ : await lastActivityWithinTurn(ctx, {
1534
+ appScope: input.appScope,
1535
+ turnStartedAtMs,
1536
+ });
1537
+ const quietMs = Math.max(0, currentTimeMs - lastActivityAtMs);
1538
+ const alreadyHinted = input.stored?.stallHintedAtMs !== undefined;
1539
+ const stallHint = quiet && !alreadyHinted && quietMs >= ctx.config.clarkStallHintAfterMs
1540
+ ? { quietMs, reason: "long_quiet" }
1541
+ : undefined;
1542
+ // Only what this poll actually heard, and whether or not this caller has a
1543
+ // cursor, so a reader that never had one still measures silence from when
1544
+ // the build last spoke. A quiet poll writing its own read back would clobber
1545
+ // activity a concurrent reader heard between that read and this write.
1546
+ if (input.saidSomething) {
1547
+ await rememberLastActivity(ctx, {
1548
+ ...input.appScope,
1549
+ atMs: currentTimeMs,
1550
+ });
1551
+ }
1552
+ if (input.cursorKey && ctx.progressCursors) {
1553
+ const advanceCursor = input.advanceCursor !== false;
1554
+ const cursor = advanceCursor
1555
+ ? (input.snapshot?.cursor ?? input.stored?.cursor ?? EMPTY_EVENT_CURSOR)
1556
+ : (input.stored?.cursor ?? EMPTY_EVENT_CURSOR);
1557
+ // Hearing anything closes the quiet spell, so the next silence gets its
1558
+ // own hint rather than being suppressed by the previous one.
1559
+ const hintedAtMs = stallHint
1560
+ ? currentTimeMs
1561
+ : quiet
1562
+ ? input.stored?.stallHintedAtMs
1563
+ : undefined;
1564
+ await ctx.progressCursors.setCursor({
1565
+ ...input.cursorKey,
1566
+ record: {
1567
+ cursor,
1568
+ ...(hintedAtMs === undefined ? {} : { stallHintedAtMs: hintedAtMs }),
1569
+ },
1570
+ });
1571
+ }
1572
+ return {
1573
+ pacing: {
1574
+ ...(input.snapshot && input.advanceCursor !== false
1575
+ ? { cursor: input.snapshot.cursor }
1576
+ : input.stored
1577
+ ? { cursor: input.stored.cursor }
1578
+ : {}),
1579
+ nextPollAfterMs: input.terminal
1580
+ ? 0
1581
+ : nextPollAfterMs(ctx, input.applicationId),
1582
+ ...(input.turnRunning
1583
+ ? {
1584
+ remainingTurnBudgetMs: Math.max(0, ctx.config.clarkTurnTimeoutMs - input.elapsedMs),
1585
+ }
1586
+ : {}),
1587
+ ...(input.snapshot && input.snapshot.lost.length > 0
1588
+ ? { staleStreams: input.snapshot.lost }
1589
+ : {}),
1590
+ },
1591
+ ...(stallHint ? { stallHint } : {}),
1592
+ };
1593
+ }
1594
+ /**
1595
+ * Pacing for a caller that is being handed a starting point rather than a
1596
+ * batch of activity.
1597
+ *
1598
+ * Reads the caller's stored cursor and hands it straight back without
1599
+ * advancing it. `get_app` reports no activity, so moving the cursor here would
1600
+ * mark events as read that nobody has read — the caller's next
1601
+ * `check_app_progress` would silently skip them.
1602
+ *
1603
+ * Carries no `remainingTurnBudgetMs`. `get_app` is not the tool that follows a
1604
+ * turn and never has the turn's own clock, so any number it put there would be
1605
+ * the full budget regardless of how long the turn had been running - or
1606
+ * regardless of whether one was running at all, which is what it used to send
1607
+ * alongside a preview build (ENG-5808). `check_app_progress` is where a caller
1608
+ * following a turn reads what is left of it.
1609
+ */
1610
+ async function buildPacing(ctx, input) {
1611
+ const stored = ctx.caller
1612
+ ? await ctx.progressCursors?.getCursor({
1613
+ applicationId: input.applicationId,
1614
+ caller: ctx.caller,
1615
+ organizationId: input.organizationId,
1616
+ })
1617
+ : undefined;
1618
+ return {
1619
+ cursor: stored?.cursor ?? EMPTY_EVENT_CURSOR,
1620
+ nextPollAfterMs: input.terminal
1621
+ ? 0
1622
+ : nextPollAfterMs(ctx, input.applicationId),
1623
+ };
1624
+ }
1625
+ /**
1626
+ * How soon the caller should poll again. Once the app is building it runs for
1627
+ * minutes, so pace at clarkBuildPollAfterMs (~30s) to stay inside a host's
1628
+ * tool-call budget; while it is still planning, keep the shorter progress wait
1629
+ * so a plan or question surfaces quickly.
1630
+ */
1631
+ function nextPollAfterMs(ctx, applicationId) {
1632
+ const cadenceMs = ctx.sessionPeer.buildMode?.(applicationId)
1633
+ ? ctx.config.clarkBuildPollAfterMs
1634
+ : ctx.config.clarkProgressWaitMs;
1635
+ return hostCallBudgetMs(ctx, cadenceMs);
1636
+ }
1637
+ export async function editApp(ctx, input) {
1638
+ const principalResult = await ensurePrincipal(ctx);
1639
+ if (principalResult.kind !== "ok") {
1640
+ return principalResult;
1641
+ }
1642
+ const principal = principalResult.value;
1643
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
1644
+ if (resolved.kind !== "ok") {
1645
+ return resolved;
1646
+ }
1647
+ const applicationId = resolved.value;
1648
+ return namingApplication(applicationId, await promptExistingApplication(ctx, { applicationId, input, principal }));
1649
+ }
1650
+ async function promptExistingApplication(ctx, args) {
1651
+ const { applicationId, input, principal } = args;
1652
+ // Approve reuses the pending plan as the prompt. Every other path needs the
1653
+ // caller's words — an empty or missing prompt would otherwise forward
1654
+ // undefined into Clark.
1655
+ if (input.planAction !== "approve" &&
1656
+ !(typeof input.prompt === "string" && input.prompt.trim().length > 0)) {
1657
+ return {
1658
+ kind: "error",
1659
+ code: "prompt_required",
1660
+ message: 'edit_app needs a prompt, unless planAction is "approve" (Build it) for a pending plan.',
1661
+ };
1662
+ }
1663
+ ctx.onProgress?.({ message: "Ensuring live-edit session", type: "progress" });
1664
+ let session;
1665
+ try {
1666
+ session = await openLiveEdit(ctx, {
1667
+ apiKey: principal.apiKey,
1668
+ applicationId,
1669
+ branch: input.branch,
1670
+ organizationId: principal.organizationId,
1671
+ });
1672
+ }
1673
+ catch (error) {
1674
+ rememberFailedStart(ctx, { applicationId, error, principal });
1675
+ return liveEditUnavailable(error);
1676
+ }
1677
+ ctx.onProgress?.({
1678
+ message: "Forwarding prompt to Superblocks",
1679
+ type: "progress",
1680
+ });
1681
+ const pending = ctx.sessionPeer.pendingPlan?.(applicationId);
1682
+ // Approve / refine a pending plan the way the editor's "Build it" / chat
1683
+ // refine path does. A bare prompt while a plan is pending is refine feedback.
1684
+ const effectiveAction = input.planAction ?? (pending ? "refine" : undefined);
1685
+ // Clark's headless default is PLAN, so a follow-up prompt after a BUILD turn
1686
+ // used to propose a plan instead of continuing the build. Preserve BUILD
1687
+ // until the caller explicitly asks to plan, regardless of how BUILD began.
1688
+ const defaultMode = ctx.sessionPeer.buildMode?.(applicationId)
1689
+ ? "BUILD"
1690
+ : "PLAN";
1691
+ let promptArgs = {
1692
+ applicationId,
1693
+ branch: input.branch,
1694
+ idempotencyKey: input.idempotencyKey,
1695
+ liveEditId: session.liveEditId,
1696
+ liveEditUrl: session.liveEditUrl,
1697
+ mode: input.mode ?? defaultMode,
1698
+ organizationId: principal.organizationId,
1699
+ prompt: input.prompt ?? "",
1700
+ userId: principal.userId,
1701
+ };
1702
+ if (effectiveAction === "approve") {
1703
+ if (pending?.plan) {
1704
+ promptArgs = {
1705
+ ...promptArgs,
1706
+ mode: "BUILD",
1707
+ planContext: {
1708
+ approved: true,
1709
+ plan: pending.plan,
1710
+ ...(pending.title ? { title: pending.title } : {}),
1711
+ },
1712
+ prompt: pending.plan,
1713
+ responseMetadata: {
1714
+ approved: true,
1715
+ ...(pending.messageId
1716
+ ? { responseToMessageId: pending.messageId }
1717
+ : {}),
1718
+ type: "plan_response",
1719
+ },
1720
+ };
1721
+ }
1722
+ else if (awaitingDecision(ctx, {
1723
+ applicationId,
1724
+ organizationId: principal.organizationId,
1725
+ userId: principal.userId,
1726
+ })) {
1727
+ // Clark is waiting on the user but streamed its plan as chat text, so no
1728
+ // plan card was captured. "Build it" still means build: forward a build
1729
+ // turn the way the editor's Build-it does. Clark still holds the plan in
1730
+ // its own context, so the caller's words (or a plain go-ahead) suffice.
1731
+ promptArgs = {
1732
+ ...promptArgs,
1733
+ mode: "BUILD",
1734
+ planContext: { approved: true },
1735
+ prompt: input.prompt?.trim() ||
1736
+ "The plan is approved. Build it as described.",
1737
+ responseMetadata: { approved: true, type: "plan_response" },
1738
+ };
1739
+ }
1740
+ else {
1741
+ return {
1742
+ kind: "error",
1743
+ code: "plan_required",
1744
+ message: "No pending Superblocks plan to approve for this application. Start or edit in Plan mode first, then approve after needs_decision.",
1745
+ };
1746
+ }
1747
+ }
1748
+ else if (effectiveAction === "refine" && pending?.plan) {
1749
+ promptArgs = {
1750
+ ...promptArgs,
1751
+ mode: "PLAN",
1752
+ planContext: {
1753
+ approved: false,
1754
+ plan: pending.plan,
1755
+ ...(pending.title ? { title: pending.title } : {}),
1756
+ },
1757
+ responseMetadata: {
1758
+ approved: false,
1759
+ ...(pending.messageId
1760
+ ? { responseToMessageId: pending.messageId }
1761
+ : {}),
1762
+ type: "plan_response",
1763
+ },
1764
+ };
1765
+ }
1766
+ if (input.wait === "ack") {
1767
+ const started = await startClarkPrompt(ctx, promptArgs);
1768
+ if (started.kind !== "ok") {
1769
+ return started;
1770
+ }
1771
+ await rememberApplication(ctx, principal, applicationId);
1772
+ return {
1773
+ kind: "ok",
1774
+ value: await withBrowser(ctx, applicationId, {
1775
+ activity: started.value.activity,
1776
+ applicationId,
1777
+ editUrl: editUrl(ctx.config, applicationId),
1778
+ mode: promptArgs.mode,
1779
+ status: "building",
1780
+ }),
1781
+ };
1782
+ }
1783
+ const forward = await forwardClarkPrompt(ctx, promptArgs);
1784
+ if (forward.kind !== "ok") {
1785
+ return forward;
1786
+ }
1787
+ await rememberApplication(ctx, principal, applicationId);
1788
+ const result = {
1789
+ applicationId,
1790
+ ...(forward.value.checkpointCommitId
1791
+ ? { checkpointCommitId: forward.value.checkpointCommitId }
1792
+ : {}),
1793
+ reply: forward.value.reply,
1794
+ ...(forward.value.decision ? { decision: forward.value.decision } : {}),
1795
+ editUrl: editUrl(ctx.config, applicationId),
1796
+ generationStatus: forward.value.generationStatus,
1797
+ mode: promptArgs.mode,
1798
+ ...(forward.value.recentRuns && forward.value.recentRuns.length > 0
1799
+ ? { recentRuns: forward.value.recentRuns }
1800
+ : {}),
1801
+ status: buildStatusOf(forward.value.generationStatus),
1802
+ viewStatus: forward.value.viewStatus,
1803
+ };
1804
+ ctx.onProgress?.({
1805
+ message: turnSummary(forward.value),
1806
+ type: "completed",
1807
+ });
1808
+ return {
1809
+ kind: "ok",
1810
+ value: await withBrowser(ctx, applicationId, result),
1811
+ };
1812
+ }
1813
+ /**
1814
+ * Shows the app's current work on a real URL without deploying it - the
1815
+ * editor's Preview button, driven from here.
1816
+ *
1817
+ * Three steps, the same ones and in the same order as the editor: snapshot the
1818
+ * live-edit state as a commit, make sure a build exists for that commit's
1819
+ * content, and hand back the URL that serves it. Builds are keyed on content
1820
+ * rather than on the commit, so previewing work that was already built (by an
1821
+ * earlier preview, or by a publish) waits on that build instead of starting a
1822
+ * second one.
1823
+ */
1824
+ export async function previewApp(ctx, input) {
1825
+ const principalResult = await ensurePrincipal(ctx);
1826
+ if (principalResult.kind !== "ok") {
1827
+ return principalResult;
1828
+ }
1829
+ const principal = principalResult.value;
1830
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
1831
+ if (resolved.kind !== "ok") {
1832
+ return resolved;
1833
+ }
1834
+ const applicationId = resolved.value;
1835
+ // Not "Committing current work": a preview commits whether or not Superblocks
1836
+ // wrote anything, and saying there is work to commit before finding out is the
1837
+ // gateway narrating an intention as a fact (ENG-5808).
1838
+ ctx.onProgress?.({ message: "Preparing a preview", type: "progress" });
1839
+ let commit;
1840
+ try {
1841
+ commit = await ctx.server.createCommit({
1842
+ apiKey: principal.apiKey,
1843
+ applicationId,
1844
+ branch: input.branch,
1845
+ message: "Preview",
1846
+ });
1847
+ }
1848
+ catch (error) {
1849
+ if (error instanceof ServerHttpError &&
1850
+ (error.status === 401 || error.status === 403)) {
1851
+ return elicitStepUp(ctx, `stepup:preview:${applicationId}`, applicationId);
1852
+ }
1853
+ return {
1854
+ kind: "error",
1855
+ code: "commit_failed",
1856
+ message: error instanceof Error ? error.message : String(error),
1857
+ };
1858
+ }
1859
+ const directoryHash = commit.directoryHash;
1860
+ if (!directoryHash) {
1861
+ return {
1862
+ kind: "error",
1863
+ code: "preview_unavailable",
1864
+ 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.",
1865
+ };
1866
+ }
1867
+ const url = previewUrl(ctx.config, applicationId, commit.commitId);
1868
+ const ready = await waitForBuild(ctx, {
1869
+ apiKey: principal.apiKey,
1870
+ applicationId,
1871
+ commitId: commit.commitId,
1872
+ directoryHash,
1873
+ wait: input.wait !== false,
1874
+ });
1875
+ if (ready.kind !== "ok") {
1876
+ return ready;
1877
+ }
1878
+ let jwt;
1879
+ if (ctx.config.deployedShellBaseUrl) {
1880
+ try {
1881
+ jwt = await ctx.server.issueScopedToken({
1882
+ apiKey: principal.apiKey,
1883
+ applicationId,
1884
+ commitId: commit.commitId,
1885
+ scope: "apps:preview",
1886
+ });
1887
+ }
1888
+ catch (error) {
1889
+ console.warn("gateway preview scoped token failed; using UI preview URL", {
1890
+ applicationId,
1891
+ message: error instanceof Error ? error.message : String(error),
1892
+ });
1893
+ }
1894
+ }
1895
+ const openable = buildOpenablePreviewUrls({
1896
+ applicationId,
1897
+ commitId: commit.commitId,
1898
+ deployedShellBaseUrl: ctx.config.deployedShellBaseUrl,
1899
+ jwt,
1900
+ publicViewerBaseUrl: ctx.config.publicViewerBaseUrl,
1901
+ uiPreviewUrl: url,
1902
+ });
1903
+ let screenshotBase64;
1904
+ let needsBrowserDriver = false;
1905
+ const gatewayChromium = await gatewayChromiumIsPresent(ctx);
1906
+ const includeScreenshot = input.includeScreenshot !== false;
1907
+ // Always capture when the preview is ready and Gateway Chromium is present.
1908
+ // urls_only must not wait on chromium.launch() (ENG-5858).
1909
+ if (!includeScreenshot) {
1910
+ console.error("gateway preview screenshot skipped", {
1911
+ applicationId,
1912
+ reason: "live library capture will run instead",
1913
+ });
1914
+ }
1915
+ else if (!ready.value.built) {
1916
+ console.error("gateway preview screenshot skipped", {
1917
+ applicationId,
1918
+ reason: "build not finished",
1919
+ });
1920
+ }
1921
+ else if (!ctx.capturePreviewScreenshot) {
1922
+ console.error("gateway preview screenshot skipped", {
1923
+ applicationId,
1924
+ reason: "no capture backend configured",
1925
+ });
1926
+ needsBrowserDriver = true;
1927
+ }
1928
+ else if (!gatewayChromium) {
1929
+ console.error("gateway preview screenshot skipped", {
1930
+ applicationId,
1931
+ reason: "no gateway chromium",
1932
+ });
1933
+ needsBrowserDriver = true;
1934
+ }
1935
+ else {
1936
+ ctx.onProgress?.({
1937
+ message: "Capturing a preview screenshot",
1938
+ type: "progress",
1939
+ });
1940
+ try {
1941
+ const shot = await ctx.capturePreviewScreenshot({
1942
+ embedToken: principal.apiKey,
1943
+ url: buildEmbedShellPreviewUrl({
1944
+ applicationId,
1945
+ deployedShellBaseUrl: ctx.config.deployedShellBaseUrl,
1946
+ uiBaseUrl: ctx.config.uiBaseUrl,
1947
+ }),
1948
+ });
1949
+ screenshotBase64 = shot?.dataBase64;
1950
+ // A capture that soft-fails looks exactly like one that was never asked
1951
+ // for, which is what made a missing demo screenshot undiagnosable.
1952
+ console.error("gateway preview screenshot captured", {
1953
+ applicationId,
1954
+ base64Length: screenshotBase64?.length ?? 0,
1955
+ });
1956
+ }
1957
+ catch (error) {
1958
+ // Screenshot is optional; never fail get_app / preview_app for it.
1959
+ console.warn("gateway preview screenshot threw; continuing with URLs", {
1960
+ message: error instanceof Error ? error.message : String(error),
1961
+ });
1962
+ }
1963
+ }
1964
+ const name = await applicationName(ctx, principal, applicationId);
1965
+ await rememberApplication(ctx, principal, applicationId);
1966
+ ctx.onProgress?.({
1967
+ message: ready.value.built
1968
+ ? `Preview ready: ${openable.previewUrl}`
1969
+ : "Preview is still building",
1970
+ type: "completed",
1971
+ });
1972
+ return {
1973
+ kind: "ok",
1974
+ value: await withBrowser(ctx, applicationId, {
1975
+ applicationId,
1976
+ commitId: commit.commitId,
1977
+ editUrl: editUrl(ctx.config, applicationId),
1978
+ ...(name ? { name } : {}),
1979
+ ...(needsBrowserDriver ? { needsBrowserDriver: true } : {}),
1980
+ openableViaHttps: openable.openableViaHttps,
1981
+ previewUrl: openable.previewUrl,
1982
+ ...(screenshotBase64 ? { screenshotBase64 } : {}),
1983
+ status: ready.value.built ? "ready" : "building",
1984
+ viewerUrl: openable.viewerUrl,
1985
+ }),
1986
+ };
1987
+ }
1988
+ /**
1989
+ * The app's display name, or undefined when it cannot be read. A label is
1990
+ * never worth failing a preview over, so every failure here is swallowed.
1991
+ */
1992
+ async function applicationName(ctx, principal, applicationId) {
1993
+ try {
1994
+ return await ctx.server.getApplicationName({
1995
+ apiKey: principal.apiKey,
1996
+ applicationId,
1997
+ });
1998
+ }
1999
+ catch (error) {
2000
+ console.warn("gateway application name lookup failed", {
2001
+ applicationId,
2002
+ message: error instanceof Error ? error.message : String(error),
2003
+ });
2004
+ return undefined;
2005
+ }
2006
+ }
2007
+ /**
2008
+ * Whether the app's last turn finished waiting on the user. True even when the
2009
+ * collector captured no structured plan/question (Clark streamed it as chat
2010
+ * text), so approve and get_app can treat a decision-less needs_decision the
2011
+ * same as a plan card instead of dead-ending or previewing the template.
2012
+ */
2013
+ function awaitingDecision(ctx, scope) {
2014
+ const turn = ctx.sessionPeer.turnState?.(scope);
2015
+ return (turn?.phase === "finished" && turn.generationStatus === "needs_decision");
2016
+ }
2017
+ function emptyTurnStatus(status) {
2018
+ switch (status) {
2019
+ case "cancelled":
2020
+ case "live_edit_terminated":
2021
+ case "no_changes":
2022
+ case "timeout":
2023
+ return status;
2024
+ default:
2025
+ return undefined;
2026
+ }
2027
+ }
2028
+ /**
2029
+ * Why the app cannot be previewed, read from the turn this process is holding.
2030
+ */
2031
+ function localUnbuiltReason(turn, pending) {
2032
+ if (turn?.phase === "running") {
2033
+ return { kind: "running" };
2034
+ }
2035
+ if (pending) {
2036
+ return { decision: pending.decision, kind: "awaiting_decision" };
2037
+ }
2038
+ if (turn?.phase !== "finished") {
2039
+ return undefined;
2040
+ }
2041
+ // Clark can end a turn awaiting the user having streamed its plan or question
2042
+ // as ordinary chat text, so the collector captured no structured decision.
2043
+ // The turn is still a decision - previewing here is what commits and builds
2044
+ // the untouched template into a blank card.
2045
+ if (turn.generationStatus === "needs_decision") {
2046
+ return { kind: "awaiting_decision" };
2047
+ }
2048
+ // A turn that ended without a checkpoint wrote nothing, so the only thing
2049
+ // left to preview is the template the app was created from - the blank card
2050
+ // this gate exists to prevent.
2051
+ if (turn.checkpointCommitId) {
2052
+ return undefined;
2053
+ }
2054
+ const status = emptyTurnStatus(turn.generationStatus);
2055
+ return status ? { kind: "empty_turn", status } : undefined;
2056
+ }
2057
+ /**
2058
+ * The same question asked of what was persisted, for a caller with no live turn
2059
+ * to ask.
2060
+ *
2061
+ * Only a status that cannot coexist with committed work is read from here. The
2062
+ * gateway checkpoints a turn that *completed*, and the stream carries no record
2063
+ * of whether that landed, so a `cancelled` in the tail may well have a real app
2064
+ * behind it and withholding its preview would hide one. A live edit that died
2065
+ * and a turn still waiting on the user both certainly never reached completed.
2066
+ *
2067
+ * A stream with no terminal state at all is a build still running as far as it
2068
+ * knows, and so is one this gateway timed out - a gateway budget is not
2069
+ * something the dev server records. Neither is grounds to withhold a preview.
2070
+ */
2071
+ async function persistedUnbuiltReason(ctx, principal, applicationId) {
2072
+ let snapshot;
2073
+ try {
2074
+ snapshot = await readSnapshot(ctx, {
2075
+ applicationId,
2076
+ // get_app is not a poll: it reads the tail, which is cursor-independent,
2077
+ // and must not move any caller's place in the stream.
2078
+ cursor: undefined,
2079
+ // Only the tail is read below, and the tail does not depend on this. It
2080
+ // keeps the call from dragging back the whole retained stream to answer
2081
+ // one question about the end of it.
2082
+ limit: 1,
2083
+ organizationId: principal.organizationId,
2084
+ });
2085
+ }
2086
+ catch (error) {
2087
+ // This is the only I/O on a path that had none. An event store having a
2088
+ // bad moment is not a reason for get_app to fail: fall back to what the
2089
+ // call answered before there was a stream to ask.
2090
+ console.warn("gateway get_app persisted state unreadable", {
2091
+ applicationId,
2092
+ error: error instanceof Error ? error.message : String(error),
2093
+ });
2094
+ return undefined;
2095
+ }
2096
+ if (!snapshot) {
2097
+ return undefined;
2098
+ }
2099
+ const { generationStatus } = readPersistedProgress(snapshot, streamLiveness(ctx), ctx.config.clarkSettleMs);
2100
+ return generationStatus ? persistedTurnEnd(generationStatus) : undefined;
2101
+ }
2102
+ /**
2103
+ * When to stop believing what the stream last said. A turn cannot outlive its
2104
+ * own budget, so work silent for longer than that has ended whatever the tail
2105
+ * still holds.
2106
+ *
2107
+ * Shared by both readers so one rule governs every status read out of a stream,
2108
+ * even where a caller happens not to use the part it governs: this reader takes
2109
+ * only `generationStatus`, which never expires, while `check_app_progress` reads
2110
+ * the status the rule applies to.
2111
+ */
2112
+ function streamLiveness(ctx) {
2113
+ return {
2114
+ nowMs: nowMs(ctx),
2115
+ staleAfterMs: ctx.config.clarkTurnTimeoutMs,
2116
+ };
2117
+ }
2118
+ /**
2119
+ * What a terminal status read from the stream says about previewing the app.
2120
+ *
2121
+ * Exhaustive on purpose: the live reader answers this same question from a
2122
+ * `TurnState`, and the two are only guaranteed to agree while every status has
2123
+ * been given an answer on both. A new one fails to compile here rather than
2124
+ * quietly defaulting to buildable.
2125
+ *
2126
+ * `no_changes` never appears. Which of `completed` and `no_changes` a turn
2127
+ * reached is decided by counting the work signals the collector saw, and the
2128
+ * stream carries no such signal - the dev server reports the state, not the
2129
+ * files. A no-work turn therefore reads as `completed` here and is previewed.
2130
+ *
2131
+ * TODO(ENG-5911): close that by persisting the gateway's own account of the
2132
+ * turn - the settled status and whether a checkpoint landed - rather than
2133
+ * adding another rule over the states the stream already has.
2134
+ */
2135
+ function persistedTurnEnd(status) {
2136
+ switch (status) {
2137
+ // TODO(ENG-5912): carry the decision itself. The stream has the plan - it
2138
+ // is an assistant `chat_message` with a `messageType` - but nothing reads
2139
+ // it back into a `TurnDecision`, so a plan resumed from here renders as a
2140
+ // plain question and cannot be approved.
2141
+ case "needs_decision":
2142
+ return { kind: "awaiting_decision" };
2143
+ case "live_edit_terminated":
2144
+ return { kind: "empty_turn", status };
2145
+ // A `cancelled` in the tail may still have a real app behind it: the
2146
+ // gateway checkpoints a turn that completed and the stream carries no
2147
+ // record of whether that landed, so withholding the preview would hide
2148
+ // one. `detached` and `timeout` are the gateway's own verdicts, which the
2149
+ // dev server never records, and `completed` is the case this gate exists
2150
+ // to let through.
2151
+ case "cancelled":
2152
+ case "completed":
2153
+ case "detached":
2154
+ case "no_changes":
2155
+ case "timeout":
2156
+ return undefined;
2157
+ default: {
2158
+ const exhaustive = status;
2159
+ return exhaustive;
2160
+ }
2161
+ }
2162
+ }
2163
+ async function unbuiltReason(ctx, principal, applicationId) {
2164
+ const turn = ctx.sessionPeer.turnState?.({
2165
+ applicationId,
2166
+ organizationId: principal.organizationId,
2167
+ userId: principal.userId,
2168
+ });
2169
+ const pending = ctx.sessionPeer.pendingDecision?.(applicationId);
2170
+ if (turn || pending) {
2171
+ return localUnbuiltReason(turn, pending);
2172
+ }
2173
+ // The local turn is an accelerator, not the record. With nothing held here -
2174
+ // after a restart, on another replica, or once a finished turn was released -
2175
+ // the persisted stream is the only account of the turn that is left, and
2176
+ // falling through without asking it is how the untouched starter template
2177
+ // gets built and shown as a finished app.
2178
+ return await persistedUnbuiltReason(ctx, principal, applicationId);
2179
+ }
2180
+ /**
2181
+ * The answer `get_app` owes a caller while the app has nothing worth showing.
2182
+ *
2183
+ * Clark's headless default is PLAN, so a first prompt commonly ends on a plan
2184
+ * or a question rather than on code. Previewing that state commits and builds
2185
+ * the untouched template, and the caller shows the blank result as if the app
2186
+ * were ready. Undefined once Clark's turn is over and a preview means something.
2187
+ */
2188
+ async function unbuiltApp(ctx, principal, applicationId) {
2189
+ const reason = await unbuiltReason(ctx, principal, applicationId);
2190
+ if (!reason) {
2191
+ return undefined;
2192
+ }
2193
+ const name = await applicationName(ctx, principal, applicationId);
2194
+ await rememberApplication(ctx, principal, applicationId);
2195
+ const app = await withBrowser(ctx, applicationId, {
2196
+ applicationId,
2197
+ editUrl: editUrl(ctx.config, applicationId),
2198
+ ...(name ? { name } : {}),
2199
+ });
2200
+ if (reason.kind === "awaiting_decision") {
2201
+ return {
2202
+ kind: "ok",
2203
+ value: {
2204
+ ...app,
2205
+ ...(reason.decision ? { decision: reason.decision } : {}),
2206
+ pendingAction: reason.decision?.kind === "plan" ? "approve_plan" : "answer_question",
2207
+ status: "needs_decision",
2208
+ },
2209
+ };
2210
+ }
2211
+ if (reason.kind === "empty_turn") {
2212
+ return {
2213
+ kind: "ok",
2214
+ value: { ...app, pendingAction: "retry_build", status: reason.status },
2215
+ };
2216
+ }
2217
+ return {
2218
+ kind: "ok",
2219
+ value: {
2220
+ ...(await buildPacing(ctx, {
2221
+ applicationId,
2222
+ organizationId: principal.organizationId,
2223
+ terminal: false,
2224
+ })),
2225
+ ...app,
2226
+ pendingAction: "wait_for_build",
2227
+ status: "building",
2228
+ },
2229
+ };
2230
+ }
2231
+ /**
2232
+ * Lovable-style lookup: editor URL always, preview URLs when a build can be
2233
+ * ensured (default). Captures a screenshot whenever the preview is ready.
2234
+ */
2235
+ export async function getApp(ctx, input) {
2236
+ const principalResult = await ensurePrincipal(ctx);
2237
+ if (principalResult.kind !== "ok") {
2238
+ return principalResult;
2239
+ }
2240
+ const principal = principalResult.value;
2241
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
2242
+ if (resolved.kind !== "ok") {
2243
+ return resolved;
2244
+ }
2245
+ const applicationId = resolved.value;
2246
+ const ensurePreview = input.ensurePreview !== false;
2247
+ const unbuilt = await unbuiltApp(ctx, principal, applicationId);
2248
+ const mayCapture = !unbuilt || unbuilt.value.status === "building";
2249
+ const live = mayCapture
2250
+ ? await ensureLibraryCaptureInput(ctx, principal, applicationId, input.branch)
2251
+ : libraryCaptureInput(ctx, applicationId);
2252
+ if (unbuilt) {
2253
+ // Plan, question, and empty-starter stays fail-closed: photographing the
2254
+ // untouched template is the bug unbuiltApp exists to prevent. A running
2255
+ // turn is different: the held canvas is the thing being written, so a
2256
+ // live shot is worth returning alongside wait_for_build.
2257
+ if (unbuilt.value.status !== "building" || !live) {
2258
+ return unbuilt;
2259
+ }
2260
+ const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2261
+ return {
2262
+ kind: "ok",
2263
+ value: {
2264
+ ...unbuilt.value,
2265
+ ...(liveShot.needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2266
+ ...(liveShot.screenshotBase64
2267
+ ? { screenshotBase64: liveShot.screenshotBase64 }
2268
+ : {}),
2269
+ },
2270
+ };
2271
+ }
2272
+ if (ensurePreview) {
2273
+ const preview = await previewApp(ctx, {
2274
+ applicationId,
2275
+ branch: input.branch,
2276
+ includeScreenshot: live === undefined,
2277
+ ...(input.wait === undefined ? {} : { wait: input.wait }),
2278
+ });
2279
+ if (preview.kind !== "ok") {
2280
+ return preview;
2281
+ }
2282
+ // A build still running is the case this pacing exists for: the caller
2283
+ // has usable URLs and needs to know when to look again. A finished one
2284
+ // gets it too, with a zero interval, so a caller does not have to treat
2285
+ // the fields' absence as a third state.
2286
+ const pacing = await buildPacing(ctx, {
2287
+ applicationId,
2288
+ organizationId: principal.organizationId,
2289
+ terminal: preview.value.status === "ready",
2290
+ });
2291
+ const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2292
+ const screenshotBase64 = live
2293
+ ? liveShot.screenshotBase64
2294
+ : preview.value.screenshotBase64;
2295
+ const needsBrowserDriver = live
2296
+ ? liveShot.needsBrowserDriver
2297
+ : preview.value.needsBrowserDriver;
2298
+ return {
2299
+ kind: "ok",
2300
+ value: {
2301
+ ...pacing,
2302
+ applicationId,
2303
+ ...(preview.value.browser ? { browser: preview.value.browser } : {}),
2304
+ commitId: preview.value.commitId,
2305
+ editUrl: preview.value.editUrl,
2306
+ ...(preview.value.name ? { name: preview.value.name } : {}),
2307
+ ...(needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2308
+ openableViaHttps: preview.value.openableViaHttps,
2309
+ previewUrl: preview.value.previewUrl,
2310
+ ...(screenshotBase64 ? { screenshotBase64 } : {}),
2311
+ status: preview.value.status,
2312
+ viewerUrl: preview.value.viewerUrl,
2313
+ },
2314
+ };
2315
+ }
2316
+ const name = await applicationName(ctx, principal, applicationId);
2317
+ await rememberApplication(ctx, principal, applicationId);
2318
+ const liveShot = await captureLiveLibraryIfNeeded(ctx, applicationId, live);
2319
+ return {
2320
+ kind: "ok",
2321
+ value: await withBrowser(ctx, applicationId, {
2322
+ applicationId,
2323
+ editUrl: editUrl(ctx.config, applicationId),
2324
+ ...(name ? { name } : {}),
2325
+ ...(liveShot.needsBrowserDriver ? { needsBrowserDriver: true } : {}),
2326
+ ...(liveShot.screenshotBase64
2327
+ ? { screenshotBase64: liveShot.screenshotBase64 }
2328
+ : {}),
2329
+ status: "unknown",
2330
+ }),
2331
+ };
2332
+ }
2333
+ /**
2334
+ * Sees a build of this content through, starting one if none exists.
2335
+ *
2336
+ * `built: false` is not a failure: the build is still going and the caller has
2337
+ * a URL that will work once it lands. Bounded by the publish wait budget — the
2338
+ * same SABS build a publish waits on, so the same patience applies.
2339
+ */
2340
+ async function waitForBuild(ctx, input) {
2341
+ // Clamped to the host ceiling even though the build's own budget is far
2342
+ // longer. A wait that outlives the host's tool-call timeout is not patience,
2343
+ // it is a call nobody is left to read the answer of.
2344
+ const deadline = nowMs(ctx) + hostCallBudgetMs(ctx, ctx.config.publishWaitTimeoutMs);
2345
+ let started = false;
2346
+ for (;;) {
2347
+ if (ctx.signal?.aborted) {
2348
+ return cancelled("get_app");
2349
+ }
2350
+ // Checked before the network call, not only after it: a budget already
2351
+ // exhausted when this loop begins (or used up by the previous
2352
+ // iteration's own round trip) should not pay for a poll whose answer is
2353
+ // discarded the moment it arrives.
2354
+ if (nowMs(ctx) >= deadline) {
2355
+ return { kind: "ok", value: { built: false } };
2356
+ }
2357
+ let progress;
2358
+ try {
2359
+ progress = await ctx.server.getBuildProgress({
2360
+ apiKey: input.apiKey,
2361
+ applicationId: input.applicationId,
2362
+ directoryHash: input.directoryHash,
2363
+ });
2364
+ }
2365
+ catch (error) {
2366
+ return {
2367
+ kind: "error",
2368
+ code: "preview_build_failed",
2369
+ message: error instanceof Error ? error.message : String(error),
2370
+ };
2371
+ }
2372
+ if (progress === "success") {
2373
+ return { kind: "ok", value: { built: true } };
2374
+ }
2375
+ // A build this call started and that then reported failure is a real
2376
+ // failure. One that was already failed when we arrived is a stale result
2377
+ // for the same content, and starting a fresh build is what the editor does.
2378
+ if ((progress === "failed" || progress === "timed_out") && started) {
2379
+ return {
2380
+ kind: "error",
2381
+ code: "preview_build_failed",
2382
+ message: `The build for this application ${progress === "timed_out" ? "timed out" : "failed"}. Open the app in the editor to see why.`,
2383
+ };
2384
+ }
2385
+ if (progress !== "running" && !started) {
2386
+ ctx.onProgress?.({ message: "Building a preview", type: "progress" });
2387
+ try {
2388
+ await ctx.server.createBuild({
2389
+ apiKey: input.apiKey,
2390
+ applicationId: input.applicationId,
2391
+ commitId: input.commitId,
2392
+ directoryHash: input.directoryHash,
2393
+ });
2394
+ }
2395
+ catch (error) {
2396
+ return {
2397
+ kind: "error",
2398
+ code: "preview_build_failed",
2399
+ message: error instanceof Error ? error.message : String(error),
2400
+ };
2401
+ }
2402
+ started = true;
2403
+ }
2404
+ // The caller asked to be told where the build stands, not to be held here
2405
+ // until it lands. Starting it above is the whole of the work; a second
2406
+ // look would be the first iteration of a wait it declined.
2407
+ if (!input.wait || nowMs(ctx) >= deadline) {
2408
+ return { kind: "ok", value: { built: false } };
2409
+ }
2410
+ ctx.onProgress?.({
2411
+ message: "Waiting for the preview build…",
2412
+ type: "progress",
2413
+ });
2414
+ // Never sleep past the deadline: the ceiling reserves the tail of the
2415
+ // host's patience for the answer to be written and read, and a full poll
2416
+ // interval spent past it comes out of that reserve.
2417
+ await sleepUntilAbort(Math.min(ctx.config.publishPollIntervalMs, deadline - nowMs(ctx)), ctx.signal);
2418
+ }
2419
+ }
2420
+ export async function publishApp(ctx, input) {
2421
+ const principalResult = await ensurePrincipal(ctx);
2422
+ if (principalResult.kind !== "ok") {
2423
+ return principalResult;
2424
+ }
2425
+ const principal = principalResult.value;
2426
+ const resolved = await resolveApplicationId(ctx, principal, input.applicationId);
2427
+ if (resolved.kind !== "ok") {
2428
+ return resolved;
2429
+ }
2430
+ const applicationId = resolved.value;
2431
+ return namingApplication(applicationId, await publishResolvedApp(ctx, { applicationId, input, principal }));
2432
+ }
2433
+ async function publishResolvedApp(ctx, args) {
2434
+ const { applicationId, input, principal } = args;
2435
+ // Deploys address a commit and never fall back to the branch head, while
2436
+ // Clark's work sits uncommitted in the live-edit session. Committing here is
2437
+ // what the editor's own publish flow does, and without it a caller who has
2438
+ // never committed could not publish at all.
2439
+ let commitId = input.commitId?.trim();
2440
+ if (!commitId) {
2441
+ ctx.onProgress?.({ message: "Committing current work", type: "progress" });
2442
+ try {
2443
+ const commit = await ctx.server.createCommit({
2444
+ apiKey: principal.apiKey,
2445
+ applicationId,
2446
+ branch: input.branch,
2447
+ message: input.prompt,
2448
+ });
2449
+ commitId = commit.commitId;
2450
+ }
2451
+ catch (error) {
2452
+ return {
2453
+ kind: "error",
2454
+ code: "commit_failed",
2455
+ message: error instanceof Error ? error.message : String(error),
2456
+ };
2457
+ }
2458
+ }
2459
+ ctx.onProgress?.({ message: "Publishing application", type: "progress" });
2460
+ try {
2461
+ const deployment = await ctx.server.deployApplication({
2462
+ apiKey: principal.apiKey,
2463
+ applicationId,
2464
+ commitId,
2465
+ });
2466
+ // Nothing is published on these two paths and nothing will be without
2467
+ // someone acting, so neither can report as success.
2468
+ if (deployment.status === "failed") {
2469
+ ctx.sessionPeer.release?.(applicationId);
2470
+ return {
2471
+ kind: "error",
2472
+ code: "publish_failed",
2473
+ message: deployment.message ?? "The Superblocks API could not deploy the app.",
2474
+ };
2475
+ }
2476
+ if (deployment.status === "awaiting_ack") {
2477
+ ctx.sessionPeer.release?.(applicationId);
2478
+ return {
2479
+ kind: "error",
2480
+ code: "publish_needs_acknowledgement",
2481
+ 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.`,
2482
+ };
2483
+ }
2484
+ const deploymentId = deployment.deploymentId;
2485
+ if (deployment.status === "queued") {
2486
+ ctx.onProgress?.({
2487
+ message: "Waiting for deploy…",
2488
+ type: "progress",
2489
+ });
2490
+ const waited = await waitForDeployLive(ctx, {
2491
+ apiKey: principal.apiKey,
2492
+ applicationId,
2493
+ commitId,
2494
+ });
2495
+ if (waited.kind !== "ok") {
2496
+ ctx.sessionPeer.release?.(applicationId);
2497
+ return waited;
2498
+ }
2499
+ }
2500
+ await rememberApplication(ctx, principal, applicationId);
2501
+ // Deploy finished — the held editor socket is no longer needed.
2502
+ ctx.sessionPeer.release?.(applicationId);
2503
+ const result = {
2504
+ applicationId,
2505
+ commitId,
2506
+ deploymentId,
2507
+ publishUrl: publishUrl(ctx.config, applicationId),
2508
+ status: "deployed",
2509
+ };
2510
+ ctx.onProgress?.({
2511
+ message: "Publish complete — app is live",
2512
+ type: "completed",
2513
+ });
2514
+ return { kind: "ok", value: result };
2515
+ }
2516
+ catch (error) {
2517
+ // Step up on the status the server actually returned. Matching words in the
2518
+ // message instead would send a "token count exceeded" 500 or anything
2519
+ // mentioning an author through a re-auth flow and bury the real failure.
2520
+ if (error instanceof ServerHttpError &&
2521
+ (error.status === 401 || error.status === 403)) {
2522
+ return elicitStepUp(ctx, `stepup:${input.idempotencyKey}`, applicationId);
2523
+ }
2524
+ return {
2525
+ kind: "error",
2526
+ code: "publish_failed",
2527
+ message: error instanceof Error ? error.message : String(error),
2528
+ };
2529
+ }
2530
+ }
2531
+ //# sourceMappingURL=lifecycle.js.map