@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.
- package/LICENSE.txt +87 -0
- package/README.md +170 -0
- package/dist/agents/resolve-orchestrator-url.d.ts +22 -0
- package/dist/agents/resolve-orchestrator-url.js +38 -0
- package/dist/agents/resolve-orchestrator-url.js.map +1 -0
- package/dist/capabilities/import-prompt.d.ts +11 -0
- package/dist/capabilities/import-prompt.js +96 -0
- package/dist/capabilities/import-prompt.js.map +1 -0
- package/dist/capabilities/lifecycle.d.ts +73 -0
- package/dist/capabilities/lifecycle.js +2531 -0
- package/dist/capabilities/lifecycle.js.map +1 -0
- package/dist/capabilities/persisted-progress.d.ts +46 -0
- package/dist/capabilities/persisted-progress.js +246 -0
- package/dist/capabilities/persisted-progress.js.map +1 -0
- package/dist/capabilities/types.d.ts +446 -0
- package/dist/capabilities/types.js +47 -0
- package/dist/capabilities/types.js.map +1 -0
- package/dist/capture/browser-contract.d.ts +36 -0
- package/dist/capture/browser-contract.js +24 -0
- package/dist/capture/browser-contract.js.map +1 -0
- package/dist/capture/browser-instructions.d.ts +9 -0
- package/dist/capture/browser-instructions.js +17 -0
- package/dist/capture/browser-instructions.js.map +1 -0
- package/dist/capture/capture-library-probe.d.ts +55 -0
- package/dist/capture/capture-library-probe.js +177 -0
- package/dist/capture/capture-library-probe.js.map +1 -0
- package/dist/capture/capture-library.d.ts +52 -0
- package/dist/capture/capture-library.js +249 -0
- package/dist/capture/capture-library.js.map +1 -0
- package/dist/capture/classify-library-paint.d.ts +21 -0
- package/dist/capture/classify-library-paint.js +13 -0
- package/dist/capture/classify-library-paint.js.map +1 -0
- package/dist/capture/host-mcp-scan.d.ts +16 -0
- package/dist/capture/host-mcp-scan.js +64 -0
- package/dist/capture/host-mcp-scan.js.map +1 -0
- package/dist/capture/library-parent-script.d.ts +34 -0
- package/dist/capture/library-parent-script.js +82 -0
- package/dist/capture/library-parent-script.js.map +1 -0
- package/dist/capture/mode.d.ts +22 -0
- package/dist/capture/mode.js +45 -0
- package/dist/capture/mode.js.map +1 -0
- package/dist/config.d.ts +141 -0
- package/dist/config.js +109 -0
- package/dist/config.js.map +1 -0
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +9 -0
- package/dist/debug.js.map +1 -0
- package/dist/deps.d.ts +36 -0
- package/dist/deps.js +2 -0
- package/dist/deps.js.map +1 -0
- package/dist/events/cursor.d.ts +43 -0
- package/dist/events/cursor.js +78 -0
- package/dist/events/cursor.js.map +1 -0
- package/dist/events/memory-event-store.d.ts +34 -0
- package/dist/events/memory-event-store.js +110 -0
- package/dist/events/memory-event-store.js.map +1 -0
- package/dist/events/merge.d.ts +23 -0
- package/dist/events/merge.js +97 -0
- package/dist/events/merge.js.map +1 -0
- package/dist/events/normalized-collector.d.ts +62 -0
- package/dist/events/normalized-collector.js +156 -0
- package/dist/events/normalized-collector.js.map +1 -0
- package/dist/events/schema.d.ts +9 -0
- package/dist/events/schema.js +93 -0
- package/dist/events/schema.js.map +1 -0
- package/dist/events/snapshot.d.ts +32 -0
- package/dist/events/snapshot.js +57 -0
- package/dist/events/snapshot.js.map +1 -0
- package/dist/events/stream-key.d.ts +2 -0
- package/dist/events/stream-key.js +31 -0
- package/dist/events/stream-key.js.map +1 -0
- package/dist/events/types.d.ts +179 -0
- package/dist/events/types.js +66 -0
- package/dist/events/types.js.map +1 -0
- package/dist/health/readiness.d.ts +99 -0
- package/dist/health/readiness.js +202 -0
- package/dist/health/readiness.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/filters.d.ts +8 -0
- package/dist/integrations/filters.js +40 -0
- package/dist/integrations/filters.js.map +1 -0
- package/dist/integrations/map.d.ts +26 -0
- package/dist/integrations/map.js +43 -0
- package/dist/integrations/map.js.map +1 -0
- package/dist/integrations/profile-key.d.ts +19 -0
- package/dist/integrations/profile-key.js +35 -0
- package/dist/integrations/profile-key.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +5 -0
- package/dist/main.js.map +1 -0
- package/dist/orchestrator/client.d.ts +62 -0
- package/dist/orchestrator/client.js +160 -0
- package/dist/orchestrator/client.js.map +1 -0
- package/dist/playwright/ensure-chromium.d.ts +26 -0
- package/dist/playwright/ensure-chromium.js +60 -0
- package/dist/playwright/ensure-chromium.js.map +1 -0
- package/dist/preview/capture-screenshot.d.ts +52 -0
- package/dist/preview/capture-screenshot.js +158 -0
- package/dist/preview/capture-screenshot.js.map +1 -0
- package/dist/preview/viewer-url.d.ts +82 -0
- package/dist/preview/viewer-url.js +128 -0
- package/dist/preview/viewer-url.js.map +1 -0
- package/dist/process/fault-barrier.d.ts +55 -0
- package/dist/process/fault-barrier.js +113 -0
- package/dist/process/fault-barrier.js.map +1 -0
- package/dist/resume/memory-progress-store.d.ts +39 -0
- package/dist/resume/memory-progress-store.js +82 -0
- package/dist/resume/memory-progress-store.js.map +1 -0
- package/dist/resume/memory-recent-app-store.d.ts +14 -0
- package/dist/resume/memory-recent-app-store.js +27 -0
- package/dist/resume/memory-recent-app-store.js.map +1 -0
- package/dist/resume/memory-turn-store.d.ts +18 -0
- package/dist/resume/memory-turn-store.js +73 -0
- package/dist/resume/memory-turn-store.js.map +1 -0
- package/dist/resume/progress-key.d.ts +21 -0
- package/dist/resume/progress-key.js +58 -0
- package/dist/resume/progress-key.js.map +1 -0
- package/dist/resume/stores.d.ts +14 -0
- package/dist/resume/stores.js +18 -0
- package/dist/resume/stores.js.map +1 -0
- package/dist/resume/types.d.ts +124 -0
- package/dist/resume/types.js +13 -0
- package/dist/resume/types.js.map +1 -0
- package/dist/sabs/agent-facing-text.d.ts +33 -0
- package/dist/sabs/agent-facing-text.js +125 -0
- package/dist/sabs/agent-facing-text.js.map +1 -0
- package/dist/sabs/browser-context.d.ts +53 -0
- package/dist/sabs/browser-context.js +91 -0
- package/dist/sabs/browser-context.js.map +1 -0
- package/dist/sabs/editor-client-methods.d.ts +147 -0
- package/dist/sabs/editor-client-methods.js +715 -0
- package/dist/sabs/editor-client-methods.js.map +1 -0
- package/dist/sabs/recent-tool-runs.d.ts +14 -0
- package/dist/sabs/recent-tool-runs.js +61 -0
- package/dist/sabs/recent-tool-runs.js.map +1 -0
- package/dist/sabs/session-peer.d.ts +351 -0
- package/dist/sabs/session-peer.js +2 -0
- package/dist/sabs/session-peer.js.map +1 -0
- package/dist/sabs/turn-collector.d.ts +152 -0
- package/dist/sabs/turn-collector.js +573 -0
- package/dist/sabs/turn-collector.js.map +1 -0
- package/dist/sabs/websocket-session-peer.d.ts +514 -0
- package/dist/sabs/websocket-session-peer.js +2487 -0
- package/dist/sabs/websocket-session-peer.js.map +1 -0
- package/dist/security/constant-time.d.ts +9 -0
- package/dist/security/constant-time.js +13 -0
- package/dist/security/constant-time.js.map +1 -0
- package/dist/server/client.d.ts +371 -0
- package/dist/server/client.js +572 -0
- package/dist/server/client.js.map +1 -0
- package/dist/server/identity-reads.d.ts +76 -0
- package/dist/server/identity-reads.js +209 -0
- package/dist/server/identity-reads.js.map +1 -0
- package/dist/start.d.ts +14 -0
- package/dist/start.js +75 -0
- package/dist/start.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +32 -0
- package/dist/telemetry/metrics.js +57 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/transports/mcp/admin-tools.d.ts +47 -0
- package/dist/transports/mcp/admin-tools.js +164 -0
- package/dist/transports/mcp/admin-tools.js.map +1 -0
- package/dist/transports/mcp/app-status-html.d.ts +15 -0
- package/dist/transports/mcp/app-status-html.js +563 -0
- package/dist/transports/mcp/app-status-html.js.map +1 -0
- package/dist/transports/mcp/decision-elicitation.d.ts +74 -0
- package/dist/transports/mcp/decision-elicitation.js +233 -0
- package/dist/transports/mcp/decision-elicitation.js.map +1 -0
- package/dist/transports/mcp/dispatch-probe.d.ts +24 -0
- package/dist/transports/mcp/dispatch-probe.js +48 -0
- package/dist/transports/mcp/dispatch-probe.js.map +1 -0
- package/dist/transports/mcp/format-tool-content.d.ts +25 -0
- package/dist/transports/mcp/format-tool-content.js +48 -0
- package/dist/transports/mcp/format-tool-content.js.map +1 -0
- package/dist/transports/mcp/mount.d.ts +9 -0
- package/dist/transports/mcp/mount.js +469 -0
- package/dist/transports/mcp/mount.js.map +1 -0
- package/dist/transports/mcp/progress-notifier.d.ts +39 -0
- package/dist/transports/mcp/progress-notifier.js +52 -0
- package/dist/transports/mcp/progress-notifier.js.map +1 -0
- 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
|