Skip to content

Commit 5151ff4

Browse files
committed
feat: sandbox preview lifecycle + mobile web preview + bud-parity workspace
Sandbox preview lifecycle (wake-on-open): - wakePreview DO method restarts an idle-stopped sandbox + relaunches the dev server from its stored command; Daytona idle-stop ignores preview traffic, so previews died mid-view with no restart until the next run - POST/GET .../sandbox/preview/{wake,status}; 'Starting preview…' loading state - Daytona sandbox.state.updated webhook receiver + SANDBOX_STATE KV cache - 30-min auto-stop; ProcessRecord persists cwd/env for faithful dev-server relaunch Mobile web preview: - metro.config.js normalizes Daytona's comma-list X-Forwarded-Host (Metro Invalid URL); Expo web bundler + output:single; phone frame + Expo Go QR - serve local Expo previews via the subdomain form so Expo Router routes cleanly Fixes: - preview reload re-hydration (StrictMode two-effect desync → No preview available) - wire Exa/Firecrawl research keys Includes the chat-first bud-parity workspace rework; replay-sharing removed (0010 drops the obsolete v2_replay_shares table).
1 parent e9949f8 commit 5151ff4

121 files changed

Lines changed: 8348 additions & 6210 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/agent-worker/src/durable-objects/agent-run-app-builder.ts

Lines changed: 160 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ interface RunAppBuilderOptions {
4848
input: AgentRunAppBuilderInput;
4949
logger: AgentRunLogger;
5050
sandbox: ProjectSandboxStub;
51+
// Harness workspace setup runs before the model streams; its progress is status
52+
// chrome (run stage / Computer panel), never visible answer prose. Anything the
53+
// model needs to know (e.g. the live preview URL) is handed to it as agentContextNote.
54+
setRunStage: (stage: string) => void;
5155
}
5256

5357
export async function runAppBuilder(
@@ -67,38 +71,45 @@ export async function runAppBuilder(
6771
if (shouldBootstrap && input.importRepoUrl) {
6872
return importRepoWorkspace({ ...options, repoUrl: input.importRepoUrl });
6973
}
70-
await runTemplateAppBuilder({ ...options, mobile, shouldBootstrap });
71-
return {};
74+
return runTemplateAppBuilder({ ...options, mobile, shouldBootstrap });
7275
}
7376

7477
async function runTemplateAppBuilder(
7578
options: RunAppBuilderOptions & { mobile: boolean; shouldBootstrap: boolean },
76-
): Promise<void> {
79+
): Promise<{ agentContextNote: string }> {
7780
const { env, input, logger, mobile, sandbox } = options;
7881
await prepareTemplateWorkspace(options);
7982
await clearBuildCache(sandbox, mobile);
8083
await snapshotAppBuilderWorkspace({ env, input, logger, sandbox });
81-
await startTemplatePreview(options);
84+
const preview = await startTemplatePreview(options);
85+
return { agentContextNote: templateContextNote(mobile, preview) };
86+
}
87+
88+
function templateContextNote(
89+
mobile: boolean,
90+
preview: { previewUrl: string; expoUrl: string | null },
91+
): string {
92+
const stack = mobile ? "Expo Router" : "Next.js";
93+
const web = mobile
94+
? " The preview is the app rendered on web (react-native-web) inside a phone frame, so verify it there in the browser."
95+
: "";
96+
const expo = preview.expoUrl
97+
? ` The user can also scan the Expo Go QR code shown beside the preview (${preview.expoUrl}) to run it on a real device.`
98+
: "";
99+
return `[context] A ${stack} workspace is scaffolded in ${APP_BUILDER_DIR} and its live preview is already running at ${preview.previewUrl}. Build the user's app by editing files under ${APP_BUILDER_DIR}; the preview hot-reloads on save.${web}${expo}`;
82100
}
83101

84102
async function prepareTemplateWorkspace(
85103
options: RunAppBuilderOptions & { mobile: boolean; shouldBootstrap: boolean },
86104
): Promise<void> {
87-
const { append, input, logger, mobile, sandbox, shouldBootstrap } = options;
88-
await append({
89-
type: "text-delta",
90-
id: "answer",
91-
delta: mobile
92-
? "Preparing the Expo workspace and live preview...\n"
93-
: "Preparing the Next.js workspace and live preview...\n",
94-
});
105+
const { input, logger, mobile, sandbox, setRunStage, shouldBootstrap } = options;
106+
setRunStage(mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace.");
95107
if (!shouldBootstrap) {
96-
await append({
97-
type: "text-delta",
98-
id: "answer",
99-
delta: "Using the restored app workspace for this follow-up...\n",
100-
});
108+
setRunStage("Restoring the app workspace.");
101109
await installAppBuilderDependencies(sandbox, logger, mobile);
110+
if (mobile) {
111+
await ensureExpoWebSupport(sandbox, logger);
112+
}
102113
return;
103114
}
104115
await resetAppBuilderDirectory(sandbox);
@@ -108,41 +119,29 @@ async function prepareTemplateWorkspace(
108119
await scaffoldAppBuilder(sandbox, logger);
109120
}
110121
await installAppBuilderDependencies(sandbox, logger, mobile);
111-
if (!mobile) {
112-
await append({
113-
type: "text-delta",
114-
id: "answer",
115-
delta: "Seeding the app files before the agent customizes them...\n",
116-
});
122+
if (mobile) {
123+
await ensureExpoWebSupport(sandbox, logger);
124+
} else {
125+
setRunStage("Seeding the starter files.");
117126
await writeAppBuilderFiles(input, sandbox);
118127
}
119128
}
120129

121130
async function startTemplatePreview(
122131
options: RunAppBuilderOptions & { mobile: boolean },
123-
): Promise<void> {
124-
const { append, env, mobile, sandbox } = options;
125-
await append({
126-
type: "text-delta",
127-
id: "answer",
128-
delta: "Starting the dev server and exposing the preview URL...\n",
129-
});
132+
): Promise<{ previewUrl: string; expoUrl: string | null }> {
133+
const { append, env, mobile, sandbox, setRunStage } = options;
134+
setRunStage("Starting the dev server.");
130135
const preview = mobile
131136
? await startExpoDevServer(env, sandbox)
132137
: await startAppBuilderDevServer(env, sandbox);
133-
const previewUrl = clientPreviewUrl(preview.previewUrl, resolvePreviewHostname(env));
138+
const previewUrl = preview.previewUrl;
134139
const expoUrl = mobile ? expoUrlFromPreview(preview.previewUrl) : null;
135140
await append({
136141
type: "data-sandbox-status",
137142
data: { v: 1, status: "ready", previewUrl, ...(expoUrl ? { expoUrl } : {}) },
138143
});
139-
await append({
140-
type: "text-delta",
141-
id: "answer",
142-
delta: expoUrl
143-
? `Preview is running:\n\n${previewUrl}\n\nScan the QR code in the App panel with Expo Go to test on a real device (${expoUrl}).\n\nContinuing with the agent build in ${APP_BUILDER_DIR}...\n`
144-
: `Preview is running:\n\n${previewUrl}\n\nContinuing with the agent build in ${APP_BUILDER_DIR}...\n`,
145-
});
144+
return { previewUrl, expoUrl };
146145
}
147146

148147
// First import run only: clone the public GitHub repo over the empty workspace,
@@ -152,7 +151,7 @@ async function startTemplatePreview(
152151
async function importRepoWorkspace(
153152
options: RunAppBuilderOptions & { repoUrl: string },
154153
): Promise<{ agentContextNote: string }> {
155-
const { append, env, input, logger, repoUrl, sandbox } = options;
154+
const { append, env, input, logger, repoUrl, sandbox, setRunStage } = options;
156155
const startedAt = Date.now();
157156
const repoRef = parseGitHubRepo(repoUrl);
158157
if (!repoRef) {
@@ -161,18 +160,14 @@ async function importRepoWorkspace(
161160
throw repoImportError("The import URL must be a public https github.com repository.");
162161
}
163162
logger.info("repo_import_started", { repoHost: repoRef.host, repoPath: repoRef.path });
164-
await append({
165-
type: "text-delta",
166-
id: "answer",
167-
delta: `Cloning ${repoRef.path} into ${APP_BUILDER_DIR}...\n`,
168-
});
163+
setRunStage(`Cloning ${repoRef.path}.`);
169164
await resetAppBuilderDirectory(sandbox);
170165
await cloneRepoOrThrow({ env, input, logger, repoRef, repoUrl, sandbox });
171166
await markImportedWorkspace(sandbox);
172167
const installRan = await installImportedDependencies(sandbox, logger);
173168
await clearBuildCache(sandbox, isMobileBuild(input));
174169
await snapshotAppBuilderWorkspace({ env, input, logger, sandbox });
175-
await emitImportReady(append, repoUrl);
170+
await emitImportReady(append);
176171
logger.info("repo_import_succeeded", {
177172
durationMs: Date.now() - startedAt,
178173
installRan,
@@ -189,12 +184,8 @@ async function importRepoWorkspace(
189184
async function restoreImportedWorkspace(
190185
options: RunAppBuilderOptions,
191186
): Promise<{ agentContextNote: string }> {
192-
const { append, env, input, logger, sandbox } = options;
193-
await append({
194-
type: "text-delta",
195-
id: "answer",
196-
delta: "Using the imported app workspace for this follow-up...\n",
197-
});
187+
const { append, env, input, logger, sandbox, setRunStage } = options;
188+
setRunStage("Restoring the imported workspace.");
198189
await installImportedDependencies(sandbox, logger);
199190
await clearBuildCache(sandbox, isMobileBuild(input));
200191
await snapshotAppBuilderWorkspace({ env, input, logger, sandbox });
@@ -306,13 +297,10 @@ async function pathExists(sandbox: ProjectSandboxStub, path: string): Promise<bo
306297
return result.success;
307298
}
308299

309-
async function emitImportReady(append: AppendChunk, repoUrl: string): Promise<void> {
300+
async function emitImportReady(append: AppendChunk): Promise<void> {
301+
// The import outcome is handed to the model via agentContextNote (importedContextNote),
302+
// so it narrates the next steps itself — only the sandbox-ready chrome is emitted here.
310303
await append({ type: "data-sandbox-status", data: { v: 1, status: "ready" } });
311-
await append({
312-
type: "text-delta",
313-
id: "answer",
314-
delta: `Imported ${repoUrl}. The agent will inspect the project and start the dev server.\n`,
315-
});
316304
}
317305

318306
function emitImportEvent(
@@ -424,11 +412,16 @@ async function startExpoDevServer(
424412
): Promise<{ previewUrl: string }> {
425413
return executeStartDevServer(
426414
{
415+
// `--web` makes the single Metro dev server also serve the react-native-web
416+
// build as a real web page at `/` (iframe-renderable in the Computer panel),
417+
// while the SAME server keeps answering exp:// manifests for Expo Go — so we
418+
// get both the in-panel preview and the QR from one process on port 8081.
427419
command: [
428420
"pnpm",
429421
"exec",
430422
"expo",
431423
"start",
424+
"--web",
432425
"--host",
433426
"lan",
434427
"--port",
@@ -636,21 +629,116 @@ async function installAppBuilderDependencies(
636629
);
637630
}
638631

639-
function resolvePreviewHostname(env: AgentRunAppBuilderEnv): string {
640-
return PreviewHostnameSchema.parse(env.PREVIEW_HOSTNAME);
632+
// Expo web (react-native-web) is what makes `expo start --web` render a real page in
633+
// the Computer panel iframe. The default template ships react-dom + react-native-web
634+
// but NOT @expo/metro-runtime, and the Metro web bundler must be selected — so ensure
635+
// all three deps are present (SDK-matched via `expo install`) and pin web.bundler=metro.
636+
// Idempotent: the dep check short-circuits restores where they're already installed.
637+
async function ensureExpoWebSupport(
638+
sandbox: ProjectSandboxStub,
639+
logger: AgentRunLogger,
640+
): Promise<void> {
641+
const alreadyInstalled = await executeShellTerminal(
642+
{
643+
command:
644+
"test -d node_modules/react-native-web && test -d node_modules/react-dom && test -d node_modules/@expo/metro-runtime",
645+
cwd: APP_BUILDER_DIR,
646+
timeoutMs: 10_000,
647+
},
648+
{ sandbox },
649+
);
650+
if (!alreadyInstalled.success) {
651+
try {
652+
await executeShellExec(
653+
{
654+
command: [
655+
"pnpm",
656+
"exec",
657+
"expo",
658+
"install",
659+
"react-dom",
660+
"react-native-web",
661+
"@expo/metro-runtime",
662+
],
663+
cwd: APP_BUILDER_DIR,
664+
env: { CI: "1", EXPO_NO_TELEMETRY: "1" },
665+
timeoutMs: 240_000,
666+
},
667+
{ sandbox },
668+
);
669+
logger.info("sandbox_expo_web_deps_installed", {});
670+
} catch (error) {
671+
logger.warn("sandbox_expo_web_deps_failed", {
672+
error: error instanceof Error ? error.message : "Unknown expo web dependency error",
673+
});
674+
}
675+
}
676+
// Force the Metro web bundler + single-page output for Expo Router web. `output:"single"`
677+
// serves a client-rendered SPA (one index.html) instead of per-request server rendering,
678+
// which does `new URL(req.url)` behind the proxy and throws. Best-effort: a no-op when the
679+
// project uses app.config.* instead of app.json. (The client-side base path is handled by
680+
// serving mobile previews under a clean subdomain URL — see buildPreviewUrl — because the
681+
// Expo dev server ignores experiments.baseUrl / EXPO_BASE_URL.)
682+
await executeShellExec(
683+
{
684+
command: [
685+
"node",
686+
"-e",
687+
'const fs=require("fs");try{const j=JSON.parse(fs.readFileSync("app.json","utf8"));j.expo=j.expo||{};j.expo.web={...(j.expo.web||{}),bundler:"metro",output:"single"};fs.writeFileSync("app.json",JSON.stringify(j,null,2));}catch(e){}',
688+
],
689+
cwd: APP_BUILDER_DIR,
690+
timeoutMs: 15_000,
691+
},
692+
{ sandbox },
693+
);
694+
await ensureMetroForwardedHostFix(sandbox);
695+
}
696+
697+
// The preview proxy chain (gateway → Daytona's multi-hop edge) delivers `X-Forwarded-Host` to
698+
// the sandbox as a COMMA-SEPARATED LIST (e.g. "gateway.trycheatcode.com, 8081-<id>.daytonaproxy01.net").
699+
// Metro's Server._processRequest does `new URL(req.url, "http://" + xForwardedHost)`, and a
700+
// comma-list host is an invalid URL — so every `.bundle` request 500s ("TypeError: Invalid URL")
701+
// and the web preview renders blank. This can't be fixed upstream (the list is assembled inside
702+
// Daytona), so we normalise the header in Metro's own config via `enhanceMiddleware`, which runs
703+
// before `_processRequest`. Wraps any existing metro.config.js; idempotent via the marker grep.
704+
async function ensureMetroForwardedHostFix(sandbox: ProjectSandboxStub): Promise<void> {
705+
const script = [
706+
'if [ -f metro.config.js ] && grep -q "x-forwarded-host" metro.config.js; then exit 0; fi',
707+
"if [ -f metro.config.js ]; then mv metro.config.js metro.config.base.js; fi",
708+
"cat > metro.config.js <<'METROEOF'",
709+
METRO_FORWARDED_HOST_CONFIG,
710+
"METROEOF",
711+
].join("\n");
712+
await executeShellExec(
713+
{ command: ["bash", "-lc", script], cwd: APP_BUILDER_DIR, timeoutMs: 15_000 },
714+
{ sandbox },
715+
);
641716
}
642717

643-
export function clientPreviewUrl(previewUrl: string, previewHostname: string): string {
644-
if (previewHostname !== "localhost:8787") {
645-
return previewUrl;
646-
}
647-
const parsed = new URL(previewUrl);
648-
if (!parsed.hostname.endsWith(".localhost")) {
649-
return previewUrl;
650-
}
651-
const encodedHost = btoa(parsed.host)
652-
.replaceAll("+", "-")
653-
.replaceAll("/", "_")
654-
.replace(/=+$/, "");
655-
return `http://localhost:8787/__sandbox/${encodedHost}${parsed.pathname}${parsed.search}${parsed.hash}`;
718+
const METRO_FORWARDED_HOST_CONFIG = `// Cheatcode: normalise the comma-separated X-Forwarded-Host the preview proxy chain injects so
719+
// Metro's Server can parse the request URL. Wraps the project's base config (or Expo's default).
720+
let config;
721+
try {
722+
config = require("./metro.config.base.js");
723+
} catch (e) {
724+
config = require("expo/metro-config").getDefaultConfig(__dirname);
725+
}
726+
const baseEnhance = config.server && config.server.enhanceMiddleware;
727+
config.server = Object.assign({}, config.server, {
728+
enhanceMiddleware: (middleware, server) => {
729+
const inner = baseEnhance ? baseEnhance(middleware, server) : middleware;
730+
return (req, res, next) => {
731+
const xfh = req.headers["x-forwarded-host"];
732+
if (typeof xfh === "string" && xfh.indexOf(",") !== -1) {
733+
req.headers["x-forwarded-host"] = xfh.split(",")[0].trim();
734+
}
735+
return inner(req, res, next);
736+
};
737+
},
738+
});
739+
module.exports = config;
740+
`;
741+
742+
function resolvePreviewHostname(env: AgentRunAppBuilderEnv): string {
743+
return PreviewHostnameSchema.parse(env.PREVIEW_HOSTNAME);
656744
}

apps/agent-worker/src/durable-objects/agent-run-artifacts.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface ArtifactEnv extends AnalyticsBindings {
2626
HYPERDRIVE: Hyperdrive;
2727
OUTPUT_DOWNLOAD_BASE_URL?: string;
2828
OUTPUT_DOWNLOAD_SIGNING_SECRET: string;
29+
PREVIEW_HOSTNAME?: string;
2930
R2_OUTPUTS: R2Bucket;
3031
R2_OUTPUTS_BUCKET_NAME?: string;
3132
}
@@ -142,12 +143,20 @@ async function resolveArtifactProjectId(
142143

143144
function signedOutputUrl(outputId: string, env: ArtifactEnv): Promise<string> {
144145
return createSignedOutputDownloadUrl({
145-
baseUrl: env.OUTPUT_DOWNLOAD_BASE_URL,
146+
baseUrl: outputDownloadBaseUrl(env),
146147
outputId,
147148
secret: env.OUTPUT_DOWNLOAD_SIGNING_SECRET,
148149
});
149150
}
150151

152+
function outputDownloadBaseUrl(env: ArtifactEnv): string | undefined {
153+
const previewHostname = env.PREVIEW_HOSTNAME?.trim();
154+
if (previewHostname === "localhost:8787" || previewHostname === "127.0.0.1:8787") {
155+
return `http://${previewHostname}`;
156+
}
157+
return env.OUTPUT_DOWNLOAD_BASE_URL;
158+
}
159+
151160
async function closeDatabase(dbHandle: DatabaseHandle, logger: AgentRunLogger): Promise<void> {
152161
try {
153162
await dbHandle.close();

0 commit comments

Comments
 (0)