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