Skip to content

Commit 81fe145

Browse files
authored
fix(sandbox): use local app runtimes (#128)
## Summary - keep persistent project workspaces source-only while resolving Next and Expo from immutable snapshot runtimes - redirect per-project dependencies and generated Next caches to local sandbox storage instead of object-store FUSE - use immutable runtime binaries for managed servers and preserve runtime selection across restarts - clean local runtime state on project reset/deletion and standardize dependency changes on pnpm - promote the verified snapshot `cheatcode-sandbox-viewer-bundle-2d91bd46205d-30812852154` ## Root cause App preparation copied the template dependency tree into the persistent `/workspace` FUSE mount. A roughly 400 MB pnpm tree entered uninterruptible I/O and timed out before the DeepSeek request was made, leaving the Browser tab without a running preview. ## Verification - snapshot build, Trivy configuration/vulnerability/secret scans, runtime smoke test, Daytona publication, and verification passed - `pnpm turbo typecheck` - `pnpm turbo lint` - `pnpm turbo build --force` - `pnpm typecheck` - `pnpm lint` - `pnpm architecture:check` - `pnpm deadcode` - `git diff --check`
1 parent 2d91bd4 commit 81fe145

12 files changed

Lines changed: 265 additions & 54 deletions

apps/agent-worker/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ small current/version namespace records and mirrors the current version to
4242
`/workspace/<workspaceSlug>/uploads/` on the user's persistent Daytona volume before exposing it.
4343
An exact replay is idempotent; uploading new bytes at the same path creates a retained version and
4444
updates the working copy. First-run app scaffolding preserves the `uploads/` directory, and restored
45-
template projects reuse a complete persistent dependency installation or repair an interrupted one
46-
instead of rebuilding the workspace. The working copy is a reserved cache: every project-bound run
45+
template projects reuse immutable snapshot runtimes or restore project-specific dependencies to the
46+
sandbox's local disk instead of copying generated package trees to persistent object-store FUSE.
47+
The working copy is a reserved cache: every project-bound run
4748
verifies its current file set before model access, restores missing, replaced, or modified files from
4849
the checksum-verified R2 version, records the exact workspace materialization separately from the
4950
immutable user-facing file metadata, and repeats that repair when the run exits. File write/delete
@@ -263,6 +264,8 @@ match. A stale snapshot or target is replaced automatically only when canonical
263264
ownership and the persistent mount are unambiguous and no other operation or run
264265
lease is active. All other contract mismatches fail closed. New and replacement
265266
sandboxes mount the user's isolated volume subpath directly at `/workspace`.
267+
Generated dependency and compiler-cache state lives under the matching project-scoped
268+
`/home/node/.cheatcode/projects/<workspaceSlug>/` directory and is deleted with the project.
266269
Account deletion clears that subpath before deleting all exactly owned sandboxes,
267270
so persistent volume data does not outlive the account.
268271

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

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@ import {
99
appBuilderGlobalStylesSource,
1010
appBuilderLayoutSource,
1111
appBuilderPageSource,
12+
appBuilderTypeScriptConfigSource,
13+
expoTypeScriptConfigSource,
1214
} from "./app-builder-template";
1315
import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host";
16+
import {
17+
EXPO_RUNTIME_BIN,
18+
EXPO_TEMPLATE_DIR,
19+
NEXT_RUNTIME_BIN,
20+
NEXT_TEMPLATE_DIR,
21+
} from "./project-sandbox-package-runtime";
1422

1523
type ProjectSandboxStub = CodeRuntimeContext["sandbox"];
1624
type AgentRunLogger = ReturnType<typeof createLogger>;
1725

1826
interface AppBuilderSeedInput {
1927
messageText: string;
28+
workspaceSlug: string;
2029
}
2130

2231
export function writeAppBuilderFiles(
@@ -46,9 +55,30 @@ export function writeAppBuilderFiles(
4655
},
4756
{ sandbox },
4857
),
58+
executeWriteFile(
59+
{
60+
path: `${dir}/tsconfig.json`,
61+
content: appBuilderTypeScriptConfigSource(input.workspaceSlug),
62+
},
63+
{ sandbox },
64+
),
4965
]).then(() => undefined);
5066
}
5167

68+
export function writeExpoRuntimeFiles(
69+
sandbox: ProjectSandboxStub,
70+
dir: string,
71+
workspaceSlug: string,
72+
): Promise<void> {
73+
return executeWriteFile(
74+
{
75+
path: `${dir}/tsconfig.json`,
76+
content: expoTypeScriptConfigSource(workspaceSlug),
77+
},
78+
{ sandbox },
79+
).then(() => undefined);
80+
}
81+
5282
export async function scaffoldExpoApp(
5383
sandbox: ProjectSandboxStub,
5484
logger: AgentRunLogger,
@@ -59,7 +89,7 @@ export async function scaffoldExpoApp(
5989
// workspace dir can win the race), silently yielding a project with no package.json at its root.
6090
// `test -f` verifies the baked, lockfile-backed layout. A missing template means the immutable
6191
// snapshot is corrupt and must fail explicitly instead of fetching a mutable generator output.
62-
if (await copyTemplateContents(sandbox, "/home/node/cheatcode-expo-template", dir)) {
92+
if (await copyTemplateContents(sandbox, EXPO_TEMPLATE_DIR, dir)) {
6393
logger.info("sandbox_expo_template_copied", { targetDir: dir });
6494
return;
6595
}
@@ -74,7 +104,7 @@ export async function scaffoldAppBuilder(
74104
): Promise<void> {
75105
// Copy template CONTENTS into the project dir (see scaffoldExpoApp): `cp -a src dst` nests as
76106
// `dst/cheatcode-next-template/` when `dst` already exists, leaving no package.json at the root.
77-
if (await copyTemplateContents(sandbox, "/home/node/cheatcode-next-template", dir)) {
107+
if (await copyTemplateContents(sandbox, NEXT_TEMPLATE_DIR, dir)) {
78108
logger.info("sandbox_next_template_copied", { targetDir: dir });
79109
return;
80110
}
@@ -121,6 +151,23 @@ export async function installAppBuilderDependencies(
121151
);
122152
}
123153

154+
export async function ensureAppBuilderRuntime(
155+
sandbox: ProjectSandboxStub,
156+
mobile: boolean,
157+
): Promise<void> {
158+
const runtimeBin = mobile ? EXPO_RUNTIME_BIN : NEXT_RUNTIME_BIN;
159+
const checked = await executeShellTerminal(
160+
{
161+
command: `test -x ${runtimeBin}`,
162+
cwd: "/workspace",
163+
timeoutMs: 10_000,
164+
},
165+
{ sandbox },
166+
);
167+
if (checked.success) return;
168+
throw missingBakedRuntimeError(mobile ? "Expo" : "Next.js");
169+
}
170+
124171
// Expo web (react-native-web) is what makes `expo start --web` render a real page in
125172
// the Computer panel iframe. The default template ships react-dom + react-native-web
126173
// but NOT @expo/metro-runtime, and the Metro web bundler must be selected — so ensure
@@ -130,25 +177,22 @@ export async function ensureExpoWebSupport(
130177
sandbox: ProjectSandboxStub,
131178
dir: string,
132179
): Promise<void> {
133-
const alreadyInstalled = await executeShellTerminal(
134-
{
135-
command:
136-
"test -d node_modules/react-native-web && test -d node_modules/react-dom && test -d node_modules/@expo/metro-runtime",
137-
cwd: dir,
138-
timeoutMs: 10_000,
139-
},
140-
{ sandbox },
141-
);
142-
if (!alreadyInstalled.success) {
143-
throw new APIError(
144-
503,
145-
"service_maintenance_unavailable",
146-
"Expo web dependencies are unavailable",
180+
try {
181+
await executeShellExec(
147182
{
148-
hint: "Rebuild the pinned Daytona snapshot and its offline Expo package store.",
149-
retriable: false,
183+
command: [
184+
"node",
185+
"-e",
186+
'for(const name of ["react-native-web","react-dom","@expo/metro-runtime"])require.resolve(name)',
187+
],
188+
cwd: dir,
189+
env: { CHEATCODE_APP_RUNTIME: "expo" },
190+
timeoutMs: 10_000,
150191
},
192+
{ sandbox },
151193
);
194+
} catch {
195+
throw missingBakedRuntimeError("Expo");
152196
}
153197
// Force the Metro web bundler + single-page output for Expo Router web. `output:"single"`
154198
// serves a client-rendered SPA (one index.html) instead of per-request server rendering,
@@ -184,7 +228,7 @@ async function copyTemplateContents(
184228
{
185229
command: `mkdir -p ${dir} && cp -a ${templateDir}/. ${dir}/ && test -f ${dir}/package.json`,
186230
cwd: "/workspace",
187-
timeoutMs: 120_000,
231+
timeoutMs: 30_000,
188232
},
189233
{ sandbox },
190234
);
@@ -223,3 +267,15 @@ function missingBakedTemplateError(template: "Expo" | "Next.js"): APIError {
223267
},
224268
);
225269
}
270+
271+
function missingBakedRuntimeError(runtime: "Expo" | "Next.js"): APIError {
272+
return new APIError(
273+
503,
274+
"service_maintenance_unavailable",
275+
`${runtime} sandbox runtime is unavailable`,
276+
{
277+
hint: "Rebuild and publish the pinned Daytona snapshot before accepting app-builder runs.",
278+
retriable: false,
279+
},
280+
);
281+
}

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

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,23 @@ import type { CodeRuntimeContext } from "@cheatcode/sandbox-contracts";
1313
import type { ProjectMode } from "@cheatcode/types/api";
1414
import type { UIMessageChunk } from "ai";
1515
import {
16+
ensureAppBuilderRuntime,
1617
ensureExpoWebSupport,
1718
installAppBuilderDependencies,
1819
scaffoldAppBuilder,
1920
scaffoldExpoApp,
2021
writeAppBuilderFiles,
22+
writeExpoRuntimeFiles,
2123
} from "./agent-run-app-builder-scaffold";
24+
import {
25+
EXPO_RUNTIME_BIN,
26+
EXPO_TEMPLATE_DIR,
27+
NEXT_RUNTIME_BIN,
28+
NEXT_TEMPLATE_DIR,
29+
projectLocalCacheDir,
30+
projectLocalModulesDir,
31+
projectLocalRuntimeDir,
32+
} from "./project-sandbox-package-runtime";
2233

2334
export type ProjectSandboxStub = CodeRuntimeContext["sandbox"];
2435
export type AgentRunLogger = ReturnType<typeof createLogger>;
@@ -182,7 +193,7 @@ function templateContextNote(workspace: AppBuilderWorkspace): string {
182193
const mobile = workspace.mobile
183194
? " For this mobile build, that internal address renders the react-native-web preview."
184195
: "";
185-
return `[context] A ${stack} workspace is scaffolded in ${workspace.dir}, and its managed dev server is running internally at http://localhost:${workspace.port}. Build the user's app by editing files under ${workspace.dir}; it hot-reloads on save. Verify it with the sandbox's headed browser at that internal localhost address.${mobile} Never request or paste an external preview or Expo URL.`;
196+
return `[context] A ${stack} workspace is scaffolded in ${workspace.dir}, and its managed dev server is running internally at http://localhost:${workspace.port}. Build the user's app by editing files under ${workspace.dir}; it hot-reloads on save. Use pnpm for dependency changes. Verify it with the sandbox's headed browser at that internal localhost address.${mobile} Never request or paste an external preview or Expo URL.`;
186197
}
187198

188199
async function prepareTemplateWorkspace(
@@ -191,9 +202,10 @@ async function prepareTemplateWorkspace(
191202
const { input, logger, sandbox, setRunStage, shouldBootstrap, workspace } = options;
192203
const mobile = workspace.mobile;
193204
setRunStage(mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace.");
205+
await ensureAppBuilderRuntime(sandbox, mobile);
194206
if (!shouldBootstrap) {
195207
setRunStage("Restoring the app workspace.");
196-
if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace.dir, mobile))) {
208+
if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace))) {
197209
await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile);
198210
}
199211
if (mobile) {
@@ -205,10 +217,10 @@ async function prepareTemplateWorkspace(
205217
throwIfRunCanceled(options.abortSignal);
206218
if (mobile) {
207219
await scaffoldExpoApp(sandbox, logger, workspace.dir);
220+
await writeExpoRuntimeFiles(sandbox, workspace.dir, workspace.slug);
208221
} else {
209222
await scaffoldAppBuilder(sandbox, logger, workspace.dir);
210223
}
211-
await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile);
212224
throwIfRunCanceled(options.abortSignal);
213225
if (mobile) {
214226
await ensureExpoWebSupport(sandbox, workspace.dir);
@@ -403,10 +415,7 @@ async function installImportedDependencies(
403415
if (!(await pathExists(sandbox, `${dir}/package.json`))) {
404416
return false;
405417
}
406-
const usesNpm = await pathExists(sandbox, `${dir}/package-lock.json`);
407-
const command = usesNpm
408-
? ["npm", "install", "--no-audit", "--no-fund"]
409-
: ["pnpm", "install", "--prefer-offline", "--network-concurrency", "4"];
418+
const command = ["pnpm", "install", "--prefer-offline", "--network-concurrency", "4"];
410419
try {
411420
await executeShellExec({ command, cwd: dir, timeoutMs: 300_000 }, { sandbox });
412421
return true;
@@ -473,7 +482,7 @@ function parseGitHubRepo(url: string): GitHubRepoRef | null {
473482
function importedContextNote(workspace: AppBuilderWorkspace, repoUrl?: string): string {
474483
const origin = repoUrl ? ` from ${repoUrl}` : "";
475484
const mobilePort = DEFAULT_MOBILE_PORT;
476-
return `[context] This project was imported${origin} into ${workspace.dir}. Inspect it, complete any setup, and start the dev server on port ${workspace.port} with code_start_dev_server (Expo on ${mobilePort} for mobile).`;
485+
return `[context] This project was imported${origin} into ${workspace.dir}. Inspect it, use pnpm for dependency changes, complete any setup, and start the dev server on port ${workspace.port} with code_start_dev_server (Expo on ${mobilePort} for mobile).`;
477486
}
478487

479488
function repoImportError(message: string): APIError {
@@ -517,9 +526,7 @@ async function startExpoDevServer(
517526
// independent among the other flags): harmless on the initial boot, and what
518527
// makes the post-edit restart (restartMobilePreview) re-crawl from a clean slate.
519528
command: [
520-
"pnpm",
521-
"exec",
522-
"expo",
529+
EXPO_RUNTIME_BIN,
523530
"start",
524531
"-c",
525532
"--web",
@@ -530,6 +537,7 @@ async function startExpoDevServer(
530537
],
531538
cwd: workspace.dir,
532539
env: {
540+
CHEATCODE_APP_RUNTIME: "expo",
533541
CI: "1",
534542
EXPO_NO_TELEMETRY: "1",
535543
...(signedUrl ? { EXPO_PACKAGER_PROXY_URL: signedUrl } : {}),
@@ -572,9 +580,7 @@ async function startAppBuilderDevServer(
572580
await executeStartDevServer(
573581
{
574582
command: [
575-
"pnpm",
576-
"exec",
577-
"next",
583+
NEXT_RUNTIME_BIN,
578584
"dev",
579585
"--webpack",
580586
"--hostname",
@@ -584,6 +590,7 @@ async function startAppBuilderDevServer(
584590
],
585591
cwd: workspace.dir,
586592
env: {
593+
CHEATCODE_APP_RUNTIME: "next",
587594
CHOKIDAR_USEPOLLING: "true",
588595
WATCHPACK_POLLING: "1000",
589596
},
@@ -616,20 +623,29 @@ async function hasExistingAppBuilderWorkspace(
616623

617624
async function hasInstalledAppBuilderDependencies(
618625
sandbox: ProjectSandboxStub,
619-
dir: string,
620-
mobile: boolean,
626+
workspace: AppBuilderWorkspace,
621627
): Promise<boolean> {
622-
const requiredPaths = mobile
628+
const templateDir = workspace.mobile ? EXPO_TEMPLATE_DIR : NEXT_TEMPLATE_DIR;
629+
const modulesDir = projectLocalModulesDir(workspace.slug);
630+
const requiredPaths = workspace.mobile
623631
? [
624-
"node_modules/.pnpm",
625-
"node_modules/@expo/metro-runtime",
626-
"node_modules/react-dom",
627-
"node_modules/react-native-web",
632+
`${modulesDir}/.pnpm`,
633+
`${modulesDir}/@expo/metro-runtime`,
634+
`${modulesDir}/react-dom`,
635+
`${modulesDir}/react-native-web`,
628636
]
629-
: ["node_modules/.pnpm", "node_modules/next", "node_modules/react", "node_modules/react-dom"];
637+
: [
638+
`${modulesDir}/.pnpm`,
639+
`${modulesDir}/next`,
640+
`${modulesDir}/react`,
641+
`${modulesDir}/react-dom`,
642+
];
630643
const result = await executeShellTerminal(
631644
{
632-
command: requiredPaths.map((path) => `test -d ${dir}/${path}`).join(" && "),
645+
command:
646+
`(cmp -s ${workspace.dir}/package.json ${templateDir}/package.json && ` +
647+
`cmp -s ${workspace.dir}/pnpm-lock.yaml ${templateDir}/pnpm-lock.yaml) || ` +
648+
`(${requiredPaths.map((path) => `test -d ${path}`).join(" && ")})`,
633649
cwd: "/workspace",
634650
timeoutMs: 10_000,
635651
},
@@ -642,6 +658,15 @@ async function resetTemplateAppBuilderDirectory(
642658
sandbox: ProjectSandboxStub,
643659
dir: string,
644660
): Promise<void> {
661+
const workspaceSlug = dir.slice(dir.lastIndexOf("/") + 1);
662+
await executeShellExec(
663+
{
664+
command: ["rm", "-rf", projectLocalRuntimeDir(workspaceSlug)],
665+
cwd: "/workspace",
666+
timeoutMs: 30_000,
667+
},
668+
{ sandbox },
669+
);
645670
await executeShellExec(
646671
{
647672
command: [
@@ -673,10 +698,11 @@ async function clearBuildCache(
673698
dir: string,
674699
mobile: boolean,
675700
): Promise<void> {
676-
const cacheDir = mobile ? ".expo" : ".next";
701+
const workspaceSlug = dir.slice(dir.lastIndexOf("/") + 1);
702+
const cacheDir = mobile ? `${dir}/.expo` : `${projectLocalCacheDir(workspaceSlug)}/next`;
677703
await executeShellExec(
678704
{
679-
command: ["rm", "-rf", `${dir}/${cacheDir}`],
705+
command: ["rm", "-rf", cacheDir],
680706
cwd: "/workspace",
681707
timeoutMs: 120_000,
682708
},

0 commit comments

Comments
 (0)