Skip to content

Commit aa11272

Browse files
authored
feat: add fast existing-file edits and resilient previews (#199)
## Summary - Show `Working` immediately after submission while preserving a single continuous elapsed timer into the real response. - Replace timestamp-based Daytona source sync with content-addressed, atomic, crash-safe synchronization and package transactions. - Add production Morph FastApply support for existing UTF-8 files through a bounded REST client and conflict-safe sandbox writes. ## What's Included ### Immediate run feedback - Adds a view-only pending assistant state before the first streamed response. - Prevents duplicate pending states during reconnects and keeps elapsed time stable. ### Resilient sandbox synchronization - Uses SHA-256 content identity instead of comparing clocks across filesystems. - Makes file replacement atomic and handles file, directory, and symlink type changes. - Uses an OS lock and a three-way, conflict-aware package transaction that releases automatically after crashes. ### Production FastApply - Adds a small first-party Morph REST package with strict validation, bounded bodies, timeouts, and transient retries. - Resolves the Morph credential lazily from Cloudflare Secrets Store inside the active run. - Adds `fs_apply` for existing text files and preserves `fs_write` for creation, binary data, and full replacements. - Commits generated edits with a compare-and-swap write so concurrent source changes cannot be overwritten. ## Architecture The agent reads an existing file from the sandbox, sends the original content plus a sparse edit to Morph, and commits the generated candidate only when the source hash still matches. Daytona local package execution now performs force-sync, command execution, and conflict-aware commit under one process lock. The web client renders submission feedback locally until the authoritative stream arrives. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | Morph integration | Bounded first-party REST client | Pre-1.0 SDK | Keeps the Worker dependency surface small and makes limits, validation, retries, and error redaction explicit. | | Existing-file writes | Compare-and-swap | Unconditional overwrite | Prevents stale model output from clobbering concurrent edits. | | Source identity | SHA-256 content hashes | Cross-filesystem mtimes | Clocks and timestamp granularity differ across Daytona object storage and native disk. | | Package transaction | One locked remote operation | Prepare/commit/abort markers | OS locks are released on process death and eliminate stale marker recovery. | | Pending UI | View-only synthetic assistant | Persisted placeholder message | Gives immediate feedback without corrupting durable chat history. | ## Edge Cases Handled | Scenario | Handling | |---|---| | Equal-size edit with older mtime | Content hashing still detects it. | | Process crash during package command | OS lock releases and durable source remains unchanged. | | Concurrent file edit during FastApply | Hash mismatch returns a retriable conflict. | | Provider redirect or oversized response | Request is rejected at the client boundary. | | Reconnect with an existing assistant response | Pending UI is not duplicated. | ## Verification - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` - [x] Deterministic synchronizer harness: content, deletion, retyping, transaction, conflict, and crash cases - [x] Production Worker dry-run with the Morph Secrets Store binding - [ ] Production browser QA after merge and snapshot promotion
1 parent cfd36a7 commit aa11272

52 files changed

Lines changed: 870 additions & 254 deletions

Some content is hidden

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

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ DAYTONA_WORKSPACE_VOLUME=cheatcode-workspaces-development
2929
DAYTONA_WEBHOOK_SIGNING_SECRET=<daytona-webhook-signing-secret>
3030
DAYTONA_ORG_ID=
3131

32+
# Required server-side existing-file edit transport. The key is resolved only
33+
# for active FastApply requests and is never exposed to the model or sandbox.
34+
MORPH_API_KEY=<morph-api-key>
35+
3236
# Optional integrations. Skipping Composio disables connected apps; skipping
3337
# DeepSeek means users rely on BYOK for that provider.
3438
COMPOSIO_API_KEY=

apps/agent-worker/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,19 @@ after model execution (and the mobile preview restart, when applicable), so the
153153
the branded loading surface over the scaffold without relying on timers or iframe inspection.
154154
The canonical app source remains on the durable `/workspace` volume. Managed Next.js previews
155155
compile from a sandbox-local one-way mirror because Daytona's object-store FUSE mount can stall
156-
webpack compilation even after the listening socket opens. A baked Python synchronizer performs a
157-
full refresh on every process start and mirrors subsequent writes and deletions within one second,
158-
including shell-based edits. The local source, dependency tree, and build cache are disposable;
156+
webpack compilation even after the listening socket opens. A baked Python synchronizer compares
157+
content identities rather than unrelated FUSE/native-disk timestamps, performs atomic file
158+
replacement, and mirrors subsequent writes and deletions within 250 ms, including equal-size and
159+
shell-based edits. The local source, dependency tree, and build cache are disposable;
159160
wake and restart reconstruct them from the durable project without changing the Files surface.
160161
The immutable sandbox exposes that synchronizer as the root-owned
161162
`/opt/cheatcode/project-source-sync.py` helper, keeping Worker-to-sandbox command arguments small
162163
and making the snapshot the source of truth for executable sandbox runtime code.
163-
Direct pnpm commands use that same native-disk source as a transaction: the runtime snapshots the
164-
durable source, executes pnpm locally, and copies only command-produced source changes back after
165-
verifying that the corresponding durable paths did not change concurrently. Long-running pnpm
164+
Direct pnpm commands use that same native-disk source as one OS-locked transaction: the runtime
165+
snapshots the durable source, executes pnpm locally without a shell, and copies only command-produced
166+
source changes back after verifying that the corresponding durable paths did not change concurrently.
167+
The kernel releases the lock if the request or package process dies, so preview synchronization cannot
168+
be stranded behind a stale marker. Long-running pnpm
166169
processes keep the durable-to-local mirror alive for hot reload. Shell-wrapped package managers are
167170
rejected because they cannot participate in this synchronization boundary; npm, Yarn, and Bun stay
168171
disabled for project workspaces.
@@ -374,6 +377,7 @@ pnpm --filter @cheatcode/agent-worker typecheck
374377
- `PREVIEW_TOKEN_SECRET`
375378
- `COMPOSIO_API_KEY`
376379
- `DEEPSEEK_PLATFORM_API_KEY`
380+
- `MORPH_API_KEY` (required server-side FastApply transport; resolved only for an active existing-file edit)
377381
- `OUTPUT_DOWNLOAD_SIGNING_SECRET` (Secrets Store binding)
378382
- `OUTPUT_DOWNLOAD_BASE_URL` (development override; production defaults to the gateway origin)
379383
- `PREVIEW_HOSTNAME` (development override; production derives the canonical app hostname)

apps/agent-worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@cheatcode/db": "workspace:*",
2121
"@cheatcode/durable-storage": "workspace:*",
2222
"@cheatcode/env": "workspace:*",
23+
"@cheatcode/morph": "workspace:*",
2324
"@cheatcode/observability": "workspace:*",
2425
"@cheatcode/preview-bridge": "workspace:*",
2526
"@cheatcode/sandbox-contracts": "workspace:*",

apps/agent-worker/src/agent-env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface AgentEnv extends AnalyticsBindings {
2020
DAYTONA_TARGET?: string;
2121
DAYTONA_WORKSPACE_VOLUME: string;
2222
HYPERDRIVE: Hyperdrive;
23+
MORPH_API_KEY: WorkerSecret;
2324
OUTPUT_DOWNLOAD_BASE_URL?: string;
2425
OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecret;
2526
PREVIEW_TOKEN_SECRET: WorkerSecret;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface AgentRunEnv extends AnalyticsBindings {
1212
DATABASE_CONTEXT_SIGNING_SECRET_AGENT: WorkerSecret;
1313
DEEPSEEK_PLATFORM_API_KEY?: WorkerSecret;
1414
HYPERDRIVE: Hyperdrive;
15+
MORPH_API_KEY: WorkerSecret;
1516
OUTPUT_DOWNLOAD_BASE_URL?: string;
1617
OUTPUT_DOWNLOAD_SIGNING_SECRET: WorkerSecret;
1718
PREVIEW_HOSTNAME?: string;

apps/agent-worker/src/durable-objects/agent-run-mastra-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { StartRunInput } from "./agent-run-schemas";
1111
import { resolveUserSkillContext } from "./agent-run-user-skills";
1212
import { resolveAgentToolCredentials } from "./agent-tool-credentials";
1313
import type { LlmCredential } from "./llm-provider";
14+
import { createMorphApplyResolver } from "./morph-provider";
1415

1516
type ProjectSandboxStub = CodeRuntimeContext["sandbox"];
1617
type ResolvedToolCredentials = Awaited<ReturnType<typeof resolveAgentToolCredentials>>;
@@ -94,6 +95,7 @@ export function createAgentRequestContext(
9495
googleToolApiKeyResolver: toolCredentials.googleToolApiKeyResolver,
9596
llmProvider: credential.transportProvider,
9697
modelId: credential.transportModelId,
98+
morphApplyResolver: createMorphApplyResolver(options.env),
9799
openaiApiKey: credential.transportProvider === "openai" ? credential.apiKey : undefined,
98100
openrouterApiKey: credential.transportProvider === "openrouter" ? credential.apiKey : undefined,
99101
projectMode: input.projectMode,

apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ async function generateWithCredential(input: {
408408
...(input.input.runIntent === "skill-creator"
409409
? {
410410
activeTools: [
411+
"fs_apply",
411412
"fs_delete",
412413
"fs_list",
413414
"fs_read",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env";
2+
import { type MorphApplyRuntime, MorphClient } from "@cheatcode/morph";
3+
4+
interface MorphProviderEnv {
5+
MORPH_API_KEY: WorkerSecret;
6+
}
7+
8+
export type MorphApplyResolver = () => Promise<MorphApplyRuntime>;
9+
10+
/** Resolves the deployment secret only when a run actually applies an existing-file edit. */
11+
export function createMorphApplyResolver(env: MorphProviderEnv): MorphApplyResolver {
12+
let pending: Promise<MorphApplyRuntime> | null = null;
13+
return () => {
14+
pending ??= resolveWorkerSecret(env.MORPH_API_KEY)
15+
.then((apiKey) => {
16+
if (!apiKey) {
17+
throw new Error("Morph API key is unavailable.");
18+
}
19+
return new MorphClient(apiKey);
20+
})
21+
.catch((error: unknown) => {
22+
pending = null;
23+
throw error;
24+
});
25+
return pending;
26+
};
27+
}

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

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { DaytonaApiError } from "@cheatcode/agent-core/tools/code";
22
import { APIError } from "@cheatcode/observability";
33
import type {
4+
SandboxCompareAndSwapFileResult,
45
SandboxDeleteFileResult,
56
SandboxListFilesResult,
67
SandboxReadFileResult,
78
SandboxSearchFilesResult,
89
SandboxWriteFileResult,
910
} from "@cheatcode/sandbox-contracts";
10-
import { decodeBase64, dirname, encodeBase64, shellQuote } from "../sandbox-support";
11+
import { encodeBase64, shellQuote } from "../sandbox-support";
1112
import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host";
1213
import {
1314
CODE_SERVER_DISPLAY_DIR,
@@ -21,7 +22,6 @@ import {
2122
} from "./project-sandbox-code-server";
2223
import {
2324
assertDeletableWorkspacePath,
24-
assertMutableWorkspacePath,
2525
buildGrepCommand,
2626
PROJECT_ARCHIVE_MAX_BYTES,
2727
PROJECT_ARCHIVE_MAX_FILES,
@@ -30,6 +30,7 @@ import {
3030
parseGrepOutput,
3131
WORKSPACE_DIR,
3232
} from "./project-sandbox-content-support";
33+
import { compareAndSwapFile, writeFile } from "./project-sandbox-file-apply";
3334
import { listSandboxFiles } from "./project-sandbox-files";
3435
import { projectLocalRuntimeDir } from "./project-sandbox-package-runtime";
3536
import { buildPreviewUrl, signedUrlToExpo } from "./project-sandbox-preview";
@@ -53,6 +54,7 @@ import {
5354
ProjectCleanupWorkspaceInputSchema,
5455
type ProjectCodeServerInput,
5556
ProjectCodeServerInputSchema,
57+
type ProjectCompareAndSwapFileInput,
5658
type ProjectDeleteFileInput,
5759
ProjectDeleteFileInputSchema,
5860
type ProjectListFilesInput,
@@ -70,7 +72,6 @@ import {
7072
ProjectWakePreviewInputSchema,
7173
type ProjectWakePreviewResult,
7274
type ProjectWriteFileInput,
73-
ProjectWriteFileInputSchema,
7475
} from "./project-sandbox-runtime";
7576
import type { SandboxRuntime } from "./project-sandbox-runtime-handle";
7677

@@ -84,6 +85,9 @@ const BROWSER_TAKEOVER_SCRIPT = "/opt/cheatcode/start-browser-takeover.sh";
8485

8586
export interface ContentOps {
8687
cleanupProjectWorkspace: (input: ProjectCleanupWorkspaceInput) => Promise<void>;
88+
compareAndSwapFile: (
89+
input: ProjectCompareAndSwapFileInput,
90+
) => Promise<SandboxCompareAndSwapFileResult>;
8791
deleteFile: (input: ProjectDeleteFileInput) => Promise<SandboxDeleteFileResult>;
8892
downloadProjectArchive: (input: ProjectArchiveInput, onFinished: () => void) => Promise<Response>;
8993
exposeBrowserTakeover: (
@@ -159,6 +163,7 @@ export function createContentOps(
159163
const context = { dependencies, runtime };
160164
return {
161165
cleanupProjectWorkspace: (input) => cleanupProjectWorkspace(context, input),
166+
compareAndSwapFile: (input) => compareAndSwapFile(runtime, input),
162167
deleteFile: (input) => deleteFile(runtime, input),
163168
downloadProjectArchive: (input, onFinished) =>
164169
downloadProjectArchive(context, input, onFinished),
@@ -267,22 +272,6 @@ async function readFile(
267272
};
268273
}
269274

270-
async function writeFile(
271-
runtime: ContentRuntime,
272-
input: ProjectWriteFileInput,
273-
): Promise<SandboxWriteFileResult> {
274-
const parsed = ProjectWriteFileInputSchema.parse(input);
275-
assertMutableWorkspacePath(parsed.path);
276-
const id = await runtime.ensureSandbox();
277-
await runtime.client().createFolder(id, dirname(parsed.path));
278-
const bytes =
279-
parsed.encoding === "base64"
280-
? decodeBase64(parsed.content)
281-
: new TextEncoder().encode(parsed.content);
282-
await runtime.client().uploadFile(id, parsed.path, bytes);
283-
return { path: parsed.path, success: true };
284-
}
285-
286275
async function listFiles(
287276
runtime: ContentRuntime,
288277
input: ProjectListFilesInput,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { APIError } from "@cheatcode/observability";
2+
import type {
3+
SandboxCompareAndSwapFileResult,
4+
SandboxWriteFileResult,
5+
} from "@cheatcode/sandbox-contracts";
6+
import { decodeBase64, dirname, shellQuote } from "../sandbox-support";
7+
import { assertMutableWorkspacePath, WORKSPACE_DIR } from "./project-sandbox-content-support";
8+
import { timeoutSeconds } from "./project-sandbox-process-support";
9+
import {
10+
type ProjectCompareAndSwapFileInput,
11+
ProjectCompareAndSwapFileInputSchema,
12+
type ProjectWriteFileInput,
13+
ProjectWriteFileInputSchema,
14+
} from "./project-sandbox-runtime";
15+
import type { SandboxRuntime } from "./project-sandbox-runtime-handle";
16+
17+
const COMPARE_AND_SWAP_MISMATCH_EXIT = 73;
18+
const COMPARE_AND_SWAP_FILE_SCRIPT = `
19+
import hashlib
20+
import os
21+
import stat
22+
import sys
23+
24+
target = sys.argv[1]
25+
candidate = sys.argv[2]
26+
expected = sys.argv[3]
27+
digest = hashlib.sha256()
28+
descriptor = os.open(target, os.O_RDONLY | os.O_NOFOLLOW)
29+
with os.fdopen(descriptor, "rb") as source:
30+
metadata = os.fstat(source.fileno())
31+
if not stat.S_ISREG(metadata.st_mode):
32+
raise RuntimeError("Edit target is not a regular file")
33+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
34+
digest.update(chunk)
35+
if digest.hexdigest() != expected:
36+
raise SystemExit(${COMPARE_AND_SWAP_MISMATCH_EXIT})
37+
os.chmod(candidate, stat.S_IMODE(metadata.st_mode))
38+
with open(candidate, "rb") as pending:
39+
os.fsync(pending.fileno())
40+
os.replace(candidate, target)
41+
`;
42+
43+
type FileApplyRuntime = Pick<SandboxRuntime, "client" | "ensureSandbox">;
44+
45+
export async function writeFile(
46+
runtime: FileApplyRuntime,
47+
input: ProjectWriteFileInput,
48+
): Promise<SandboxWriteFileResult> {
49+
const parsed = ProjectWriteFileInputSchema.parse(input);
50+
assertMutableWorkspacePath(parsed.path);
51+
const id = await runtime.ensureSandbox();
52+
await runtime.client().createFolder(id, dirname(parsed.path));
53+
const bytes =
54+
parsed.encoding === "base64"
55+
? decodeBase64(parsed.content)
56+
: new TextEncoder().encode(parsed.content);
57+
await runtime.client().uploadFile(id, parsed.path, bytes);
58+
return { path: parsed.path, success: true };
59+
}
60+
61+
export async function compareAndSwapFile(
62+
runtime: FileApplyRuntime,
63+
input: ProjectCompareAndSwapFileInput,
64+
): Promise<SandboxCompareAndSwapFileResult> {
65+
const parsed = ProjectCompareAndSwapFileInputSchema.parse(input);
66+
assertMutableWorkspacePath(parsed.path);
67+
const id = await runtime.ensureSandbox();
68+
const stagingDir = `${WORKSPACE_DIR}/.cheatcode/runtime`;
69+
const candidatePath = `${stagingDir}/file-apply-${crypto.randomUUID()}`;
70+
await runtime.client().createFolder(id, stagingDir);
71+
await runtime.client().uploadFile(id, candidatePath, new TextEncoder().encode(parsed.content));
72+
try {
73+
await executeCompareAndSwap(runtime, id, parsed, candidatePath);
74+
return { path: parsed.path, success: true };
75+
} finally {
76+
await runtime
77+
.client()
78+
.deleteFilePath(id, candidatePath, false)
79+
.catch(() => undefined);
80+
}
81+
}
82+
83+
async function executeCompareAndSwap(
84+
runtime: FileApplyRuntime,
85+
sandboxId: string,
86+
input: ProjectCompareAndSwapFileInput,
87+
candidatePath: string,
88+
): Promise<void> {
89+
const completed = await runtime.client().execute(sandboxId, {
90+
command: [
91+
"python3",
92+
"-c",
93+
COMPARE_AND_SWAP_FILE_SCRIPT,
94+
input.path,
95+
candidatePath,
96+
input.expectedSha256,
97+
]
98+
.map(shellQuote)
99+
.join(" "),
100+
cwd: WORKSPACE_DIR,
101+
timeout: timeoutSeconds(30_000),
102+
});
103+
assertCompareAndSwapSucceeded(completed.exitCode, completed.result);
104+
}
105+
106+
function assertCompareAndSwapSucceeded(exitCode: number, result: string | null | undefined): void {
107+
if (exitCode === 0) {
108+
return;
109+
}
110+
if (exitCode === COMPARE_AND_SWAP_MISMATCH_EXIT) {
111+
throw new APIError(409, "conflict_state_invalid", "File changed while the edit was prepared", {
112+
hint: "Read the latest file and retry the edit.",
113+
retriable: true,
114+
});
115+
}
116+
throw new APIError(502, "sandbox_command_failed", "Sandbox file edit failed", {
117+
hint: result?.trim().slice(-300) || "Check that the target is a regular workspace file.",
118+
retriable: false,
119+
});
120+
}

0 commit comments

Comments
 (0)