Skip to content

Commit bc8864a

Browse files
authored
fix(sandbox): run previews from local mirror (#132)
## What changed - keep `/workspace` as the durable canonical project source - run managed Next.js previews from a synchronized sandbox-local mirror - rebuild the mirror on every start/wake and propagate live writes/deletions within one second - keep Next build output on sandbox-local disk with an absolute dist path ## Why Daytona `/workspace` is object-store FUSE. Production diagnosis proved the same Next app stalled compiling `/` from that mount while an identical local-disk copy returned HTTP 200. ## Validation - `pnpm turbo skills:build` - `pnpm turbo typecheck lint build --force` (55/55 tasks) - `pnpm architecture:check` - `pnpm deadcode` - `git diff --check`
1 parent 52ebc8a commit bc8864a

4 files changed

Lines changed: 137 additions & 11 deletions

File tree

apps/agent-worker/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ matched imperative such as “build a website” or “create a mobile app” al
115115
app-builder path before model execution. That high-confidence fallback materializes the project,
116116
scaffolds its canonical workspace, and registers the managed preview even when the selected model
117117
would otherwise attempt generic shell work and finish without a Computer target.
118+
The canonical app source remains on the durable `/workspace` volume. Managed Next.js previews
119+
compile from a sandbox-local one-way mirror because Daytona's object-store FUSE mount can stall
120+
webpack compilation even after the listening socket opens. A baked Python synchronizer performs a
121+
full refresh on every process start and mirrors subsequent writes and deletions within one second,
122+
including shell-based edits. The local source, dependency tree, and build cache are disposable;
123+
wake and restart reconstruct them from the durable project without changing the Files surface.
118124

119125
AgentRun keeps one compact exact SQLite shape for run identity, replay parts, and
120126
coordination state. Dormant objects are reconciled transactionally on activation;

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ import {
2121
writeAppBuilderFiles,
2222
writeExpoRuntimeFiles,
2323
} from "./agent-run-app-builder-scaffold";
24+
import { localNextPreviewCommand } from "./app-builder-local-preview";
2425
import {
2526
EXPO_RUNTIME_BIN,
2627
EXPO_TEMPLATE_DIR,
27-
NEXT_RUNTIME_BIN,
2828
NEXT_TEMPLATE_DIR,
2929
projectLocalCacheDir,
3030
projectLocalModulesDir,
@@ -579,15 +579,11 @@ async function startAppBuilderDevServer(
579579
): Promise<void> {
580580
await executeStartDevServer(
581581
{
582-
command: [
583-
NEXT_RUNTIME_BIN,
584-
"dev",
585-
"--webpack",
586-
"--hostname",
587-
"0.0.0.0",
588-
"--port",
589-
String(workspace.port),
590-
],
582+
command: localNextPreviewCommand({
583+
port: workspace.port,
584+
sourceDir: workspace.dir,
585+
workspaceSlug: workspace.slug,
586+
}),
591587
cwd: workspace.dir,
592588
env: {
593589
CHEATCODE_APP_RUNTIME: "next",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { shellQuote } from "../sandbox-support";
2+
import { NEXT_RUNTIME_BIN, projectLocalSourceDir } from "./project-sandbox-package-runtime";
3+
4+
interface LocalPreviewCommandInput {
5+
port: number;
6+
sourceDir: string;
7+
workspaceSlug: string;
8+
}
9+
10+
const PREVIEW_MIRROR_SYNC_SCRIPT = `
11+
import os
12+
import shutil
13+
import sys
14+
import time
15+
16+
IGNORED_DIRS = {".expo", ".next", "node_modules"}
17+
LOCAL_ONLY_NAMES = IGNORED_DIRS | {"next-env.d.ts"}
18+
19+
def remove_path(path):
20+
if os.path.isdir(path) and not os.path.islink(path):
21+
shutil.rmtree(path)
22+
elif os.path.lexists(path):
23+
os.unlink(path)
24+
25+
def sync_link(source, target):
26+
link = os.readlink(source)
27+
if os.path.islink(target) and os.readlink(target) == link:
28+
return
29+
remove_path(target)
30+
os.symlink(link, target)
31+
32+
def sync_file(entry, target, force):
33+
source_stat = entry.stat(follow_symlinks=False)
34+
try:
35+
target_stat = os.stat(target, follow_symlinks=False)
36+
except FileNotFoundError:
37+
target_stat = None
38+
changed = (
39+
force
40+
or target_stat is None
41+
or not os.path.isfile(target)
42+
or source_stat.st_size != target_stat.st_size
43+
or source_stat.st_mtime_ns > target_stat.st_mtime_ns
44+
)
45+
if changed:
46+
remove_path(target)
47+
shutil.copy2(entry.path, target, follow_symlinks=False)
48+
49+
def sync_directory(source, target, force=False):
50+
if os.path.lexists(target) and not os.path.isdir(target):
51+
remove_path(target)
52+
os.makedirs(target, exist_ok=True)
53+
source_names = set()
54+
for entry in os.scandir(source):
55+
if entry.name in IGNORED_DIRS:
56+
continue
57+
source_names.add(entry.name)
58+
destination = os.path.join(target, entry.name)
59+
try:
60+
if entry.is_symlink():
61+
sync_link(entry.path, destination)
62+
elif entry.is_dir(follow_symlinks=False):
63+
sync_directory(entry.path, destination, force)
64+
elif entry.is_file(follow_symlinks=False):
65+
sync_file(entry, destination, force)
66+
except FileNotFoundError:
67+
continue
68+
for entry in os.scandir(target):
69+
if entry.name not in source_names and entry.name not in LOCAL_ONLY_NAMES:
70+
remove_path(entry.path)
71+
72+
def synchronize(source, target, force):
73+
sync_directory(source, target, force)
74+
75+
source_dir, target_dir, mode = sys.argv[1:4]
76+
if mode == "once":
77+
synchronize(source_dir, target_dir, True)
78+
else:
79+
while True:
80+
try:
81+
synchronize(source_dir, target_dir, False)
82+
except OSError:
83+
pass
84+
time.sleep(0.75)
85+
`;
86+
87+
/** Runs Next from native sandbox disk while `/workspace` remains the durable project source. */
88+
export function localNextPreviewCommand(input: LocalPreviewCommandInput): string[] {
89+
const localSourceDir = projectLocalSourceDir(input.workspaceSlug);
90+
const syncCommand = ["python3", "-c", PREVIEW_MIRROR_SYNC_SCRIPT, input.sourceDir, localSourceDir]
91+
.map(shellQuote)
92+
.join(" ");
93+
const nextCommand = [
94+
NEXT_RUNTIME_BIN,
95+
"dev",
96+
"--webpack",
97+
"--hostname",
98+
"0.0.0.0",
99+
"--port",
100+
String(input.port),
101+
]
102+
.map(shellQuote)
103+
.join(" ");
104+
const command = [
105+
`${syncCommand} once || exit $?`,
106+
`${syncCommand} loop &`,
107+
"sync_pid=$!",
108+
`cd ${shellQuote(localSourceDir)} || exit $?`,
109+
`${nextCommand} &`,
110+
"app_pid=$!",
111+
'terminate() { kill -TERM "$app_pid" "$sync_pid" 2>/dev/null || true; }',
112+
"trap terminate HUP INT TERM",
113+
'wait "$app_pid"',
114+
"status=$?",
115+
'kill "$sync_pid" 2>/dev/null || true',
116+
'wait "$sync_pid" 2>/dev/null || true',
117+
'exit "$status"',
118+
].join("\n");
119+
return ["sh", "-lc", command];
120+
}

apps/agent-worker/src/durable-objects/project-sandbox-package-runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export function projectLocalModulesDir(workspaceSlug: string): string {
1919
return `${projectLocalRuntimeDir(workspaceSlug)}/node_modules`;
2020
}
2121

22+
export function projectLocalSourceDir(workspaceSlug: string): string {
23+
return `${projectLocalRuntimeDir(workspaceSlug)}/source`;
24+
}
25+
2226
export function projectLocalCacheDir(workspaceSlug: string): string {
2327
return `${projectLocalRuntimeDir(workspaceSlug)}/cache`;
2428
}
@@ -54,7 +58,7 @@ export function projectPackageEnvironment(
5458
const fallbackRuntime = preferredRuntime === "expo" ? "next" : "expo";
5559
return {
5660
...requested,
57-
CHEATCODE_NEXT_DIST_DIR: `../../home/node/.cheatcode/projects/${workspaceSlug}/cache/next`,
61+
CHEATCODE_NEXT_DIST_DIR: `${projectLocalCacheDir(workspaceSlug)}/next`,
5862
NODE_PATH: [
5963
modulesDir,
6064
`${APP_RUNTIME_ROOT}/${preferredRuntime}/node_modules`,

0 commit comments

Comments
 (0)