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