Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ import {
appBuilderGlobalStylesSource,
appBuilderLayoutSource,
appBuilderPageSource,
appBuilderTypeScriptConfigSource,
expoTypeScriptConfigSource,
} from "./app-builder-template";
import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host";
import {
EXPO_RUNTIME_BIN,
EXPO_TEMPLATE_DIR,
NEXT_RUNTIME_BIN,
NEXT_TEMPLATE_DIR,
} from "./project-sandbox-package-runtime";

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

interface AppBuilderSeedInput {
messageText: string;
workspaceSlug: string;
}

export function writeAppBuilderFiles(
Expand Down Expand Up @@ -46,9 +55,30 @@ export function writeAppBuilderFiles(
},
{ sandbox },
),
executeWriteFile(
{
path: `${dir}/tsconfig.json`,
content: appBuilderTypeScriptConfigSource(input.workspaceSlug),
},
{ sandbox },
),
]).then(() => undefined);
}

export function writeExpoRuntimeFiles(
sandbox: ProjectSandboxStub,
dir: string,
workspaceSlug: string,
): Promise<void> {
return executeWriteFile(
{
path: `${dir}/tsconfig.json`,
content: expoTypeScriptConfigSource(workspaceSlug),
},
{ sandbox },
).then(() => undefined);
}

export async function scaffoldExpoApp(
sandbox: ProjectSandboxStub,
logger: AgentRunLogger,
Expand All @@ -59,7 +89,7 @@ export async function scaffoldExpoApp(
// workspace dir can win the race), silently yielding a project with no package.json at its root.
// `test -f` verifies the baked, lockfile-backed layout. A missing template means the immutable
// snapshot is corrupt and must fail explicitly instead of fetching a mutable generator output.
if (await copyTemplateContents(sandbox, "/home/node/cheatcode-expo-template", dir)) {
if (await copyTemplateContents(sandbox, EXPO_TEMPLATE_DIR, dir)) {
logger.info("sandbox_expo_template_copied", { targetDir: dir });
return;
}
Expand All @@ -74,7 +104,7 @@ export async function scaffoldAppBuilder(
): Promise<void> {
// Copy template CONTENTS into the project dir (see scaffoldExpoApp): `cp -a src dst` nests as
// `dst/cheatcode-next-template/` when `dst` already exists, leaving no package.json at the root.
if (await copyTemplateContents(sandbox, "/home/node/cheatcode-next-template", dir)) {
if (await copyTemplateContents(sandbox, NEXT_TEMPLATE_DIR, dir)) {
logger.info("sandbox_next_template_copied", { targetDir: dir });
return;
}
Expand Down Expand Up @@ -121,6 +151,23 @@ export async function installAppBuilderDependencies(
);
}

export async function ensureAppBuilderRuntime(
sandbox: ProjectSandboxStub,
mobile: boolean,
): Promise<void> {
const runtimeBin = mobile ? EXPO_RUNTIME_BIN : NEXT_RUNTIME_BIN;
const checked = await executeShellTerminal(
{
command: `test -x ${runtimeBin}`,
cwd: "/workspace",
timeoutMs: 10_000,
},
{ sandbox },
);
if (checked.success) return;
throw missingBakedRuntimeError(mobile ? "Expo" : "Next.js");
}

// Expo web (react-native-web) is what makes `expo start --web` render a real page in
// the Computer panel iframe. The default template ships react-dom + react-native-web
// but NOT @expo/metro-runtime, and the Metro web bundler must be selected — so ensure
Expand All @@ -130,25 +177,22 @@ export async function ensureExpoWebSupport(
sandbox: ProjectSandboxStub,
dir: string,
): Promise<void> {
const alreadyInstalled = await executeShellTerminal(
{
command:
"test -d node_modules/react-native-web && test -d node_modules/react-dom && test -d node_modules/@expo/metro-runtime",
cwd: dir,
timeoutMs: 10_000,
},
{ sandbox },
);
if (!alreadyInstalled.success) {
throw new APIError(
503,
"service_maintenance_unavailable",
"Expo web dependencies are unavailable",
try {
await executeShellExec(
{
hint: "Rebuild the pinned Daytona snapshot and its offline Expo package store.",
retriable: false,
command: [
"node",
"-e",
'for(const name of ["react-native-web","react-dom","@expo/metro-runtime"])require.resolve(name)',
],
cwd: dir,
env: { CHEATCODE_APP_RUNTIME: "expo" },
timeoutMs: 10_000,
},
{ sandbox },
);
} catch {
throw missingBakedRuntimeError("Expo");
}
// Force the Metro web bundler + single-page output for Expo Router web. `output:"single"`
// serves a client-rendered SPA (one index.html) instead of per-request server rendering,
Expand Down Expand Up @@ -184,7 +228,7 @@ async function copyTemplateContents(
{
command: `mkdir -p ${dir} && cp -a ${templateDir}/. ${dir}/ && test -f ${dir}/package.json`,
cwd: "/workspace",
timeoutMs: 120_000,
timeoutMs: 30_000,
},
{ sandbox },
);
Expand Down Expand Up @@ -223,3 +267,15 @@ function missingBakedTemplateError(template: "Expo" | "Next.js"): APIError {
},
);
}

function missingBakedRuntimeError(runtime: "Expo" | "Next.js"): APIError {
return new APIError(
503,
"service_maintenance_unavailable",
`${runtime} sandbox runtime is unavailable`,
{
hint: "Rebuild and publish the pinned Daytona snapshot before accepting app-builder runs.",
retriable: false,
},
);
}
76 changes: 51 additions & 25 deletions apps/agent-worker/src/durable-objects/agent-run-app-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,23 @@ import type { CodeRuntimeContext } from "@cheatcode/sandbox-contracts";
import type { ProjectMode } from "@cheatcode/types/api";
import type { UIMessageChunk } from "ai";
import {
ensureAppBuilderRuntime,
ensureExpoWebSupport,
installAppBuilderDependencies,
scaffoldAppBuilder,
scaffoldExpoApp,
writeAppBuilderFiles,
writeExpoRuntimeFiles,
} from "./agent-run-app-builder-scaffold";
import {
EXPO_RUNTIME_BIN,
EXPO_TEMPLATE_DIR,
NEXT_RUNTIME_BIN,
NEXT_TEMPLATE_DIR,
projectLocalCacheDir,
projectLocalModulesDir,
projectLocalRuntimeDir,
} from "./project-sandbox-package-runtime";

export type ProjectSandboxStub = CodeRuntimeContext["sandbox"];
export type AgentRunLogger = ReturnType<typeof createLogger>;
Expand Down Expand Up @@ -182,7 +193,7 @@ function templateContextNote(workspace: AppBuilderWorkspace): string {
const mobile = workspace.mobile
? " For this mobile build, that internal address renders the react-native-web preview."
: "";
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.`;
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.`;
}

async function prepareTemplateWorkspace(
Expand All @@ -191,9 +202,10 @@ async function prepareTemplateWorkspace(
const { input, logger, sandbox, setRunStage, shouldBootstrap, workspace } = options;
const mobile = workspace.mobile;
setRunStage(mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace.");
await ensureAppBuilderRuntime(sandbox, mobile);
if (!shouldBootstrap) {
setRunStage("Restoring the app workspace.");
if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace.dir, mobile))) {
if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace))) {
await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile);
}
if (mobile) {
Expand All @@ -205,10 +217,10 @@ async function prepareTemplateWorkspace(
throwIfRunCanceled(options.abortSignal);
if (mobile) {
await scaffoldExpoApp(sandbox, logger, workspace.dir);
await writeExpoRuntimeFiles(sandbox, workspace.dir, workspace.slug);
} else {
await scaffoldAppBuilder(sandbox, logger, workspace.dir);
}
await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile);
throwIfRunCanceled(options.abortSignal);
if (mobile) {
await ensureExpoWebSupport(sandbox, workspace.dir);
Expand Down Expand Up @@ -403,10 +415,7 @@ async function installImportedDependencies(
if (!(await pathExists(sandbox, `${dir}/package.json`))) {
return false;
}
const usesNpm = await pathExists(sandbox, `${dir}/package-lock.json`);
const command = usesNpm
? ["npm", "install", "--no-audit", "--no-fund"]
: ["pnpm", "install", "--prefer-offline", "--network-concurrency", "4"];
const command = ["pnpm", "install", "--prefer-offline", "--network-concurrency", "4"];
try {
await executeShellExec({ command, cwd: dir, timeoutMs: 300_000 }, { sandbox });
return true;
Expand Down Expand Up @@ -473,7 +482,7 @@ function parseGitHubRepo(url: string): GitHubRepoRef | null {
function importedContextNote(workspace: AppBuilderWorkspace, repoUrl?: string): string {
const origin = repoUrl ? ` from ${repoUrl}` : "";
const mobilePort = DEFAULT_MOBILE_PORT;
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).`;
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).`;
}

function repoImportError(message: string): APIError {
Expand Down Expand Up @@ -517,9 +526,7 @@ async function startExpoDevServer(
// independent among the other flags): harmless on the initial boot, and what
// makes the post-edit restart (restartMobilePreview) re-crawl from a clean slate.
command: [
"pnpm",
"exec",
"expo",
EXPO_RUNTIME_BIN,
"start",
"-c",
"--web",
Expand All @@ -530,6 +537,7 @@ async function startExpoDevServer(
],
cwd: workspace.dir,
env: {
CHEATCODE_APP_RUNTIME: "expo",
CI: "1",
EXPO_NO_TELEMETRY: "1",
...(signedUrl ? { EXPO_PACKAGER_PROXY_URL: signedUrl } : {}),
Expand Down Expand Up @@ -572,9 +580,7 @@ async function startAppBuilderDevServer(
await executeStartDevServer(
{
command: [
"pnpm",
"exec",
"next",
NEXT_RUNTIME_BIN,
"dev",
"--webpack",
"--hostname",
Expand All @@ -584,6 +590,7 @@ async function startAppBuilderDevServer(
],
cwd: workspace.dir,
env: {
CHEATCODE_APP_RUNTIME: "next",
CHOKIDAR_USEPOLLING: "true",
WATCHPACK_POLLING: "1000",
},
Expand Down Expand Up @@ -616,20 +623,29 @@ async function hasExistingAppBuilderWorkspace(

async function hasInstalledAppBuilderDependencies(
sandbox: ProjectSandboxStub,
dir: string,
mobile: boolean,
workspace: AppBuilderWorkspace,
): Promise<boolean> {
const requiredPaths = mobile
const templateDir = workspace.mobile ? EXPO_TEMPLATE_DIR : NEXT_TEMPLATE_DIR;
const modulesDir = projectLocalModulesDir(workspace.slug);
const requiredPaths = workspace.mobile
? [
"node_modules/.pnpm",
"node_modules/@expo/metro-runtime",
"node_modules/react-dom",
"node_modules/react-native-web",
`${modulesDir}/.pnpm`,
`${modulesDir}/@expo/metro-runtime`,
`${modulesDir}/react-dom`,
`${modulesDir}/react-native-web`,
]
: ["node_modules/.pnpm", "node_modules/next", "node_modules/react", "node_modules/react-dom"];
: [
`${modulesDir}/.pnpm`,
`${modulesDir}/next`,
`${modulesDir}/react`,
`${modulesDir}/react-dom`,
];
const result = await executeShellTerminal(
{
command: requiredPaths.map((path) => `test -d ${dir}/${path}`).join(" && "),
command:
`(cmp -s ${workspace.dir}/package.json ${templateDir}/package.json && ` +
`cmp -s ${workspace.dir}/pnpm-lock.yaml ${templateDir}/pnpm-lock.yaml) || ` +
`(${requiredPaths.map((path) => `test -d ${path}`).join(" && ")})`,
cwd: "/workspace",
timeoutMs: 10_000,
},
Expand All @@ -642,6 +658,15 @@ async function resetTemplateAppBuilderDirectory(
sandbox: ProjectSandboxStub,
dir: string,
): Promise<void> {
const workspaceSlug = dir.slice(dir.lastIndexOf("/") + 1);
await executeShellExec(
{
command: ["rm", "-rf", projectLocalRuntimeDir(workspaceSlug)],
cwd: "/workspace",
timeoutMs: 30_000,
},
{ sandbox },
);
await executeShellExec(
{
command: [
Expand Down Expand Up @@ -673,10 +698,11 @@ async function clearBuildCache(
dir: string,
mobile: boolean,
): Promise<void> {
const cacheDir = mobile ? ".expo" : ".next";
const workspaceSlug = dir.slice(dir.lastIndexOf("/") + 1);
const cacheDir = mobile ? `${dir}/.expo` : `${projectLocalCacheDir(workspaceSlug)}/next`;
await executeShellExec(
{
command: ["rm", "-rf", `${dir}/${cacheDir}`],
command: ["rm", "-rf", cacheDir],
cwd: "/workspace",
timeoutMs: 120_000,
},
Expand Down
Loading