Skip to content

Commit f3d2af2

Browse files
committed
fix: scope-aware project deletion + per-project dev-server diagnostics
Per-project delete now cleans up ONLY that project (dev server + port + folder) instead of destroying the shared per-user sandbox (which would have wiped all the user's projects). Account deletion (Clerk user.deleted) still fully tears down the sandbox via scope='account'. - delete-state route: scope 'project'|'account' discriminator; project scope -> ProjectSandbox.cleanupProjectWorkspace(workspaceSlug) (kills app-preview:<slug>, frees its port, rm -rf /workspace/<slug>); account scope -> destroySandbox once - gateway deleteProjectRoute sends workspaceSlug + scope:project - webhooks user.deleted sends scope:account - preview.ts: keep dev_server_port_allocated/error observability Verified live: deleting one project leaves the other (its DB row + chat) intact; sandbox not destroyed. KNOWN follow-up: preview-status/wake still per-sandbox, not per-project — a project's preview can read 'running' while its dev server is down (blank preview after idle-stop until re-triggered).
1 parent 95ab2d9 commit f3d2af2

7 files changed

Lines changed: 128 additions & 15 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export async function sandboxForProject(
9696
return sandboxForUser(env, userId);
9797
}
9898

99-
async function sandboxForUser(
99+
export async function sandboxForUser(
100100
env: AgentEnv,
101101
userId: string,
102102
): Promise<DurableObjectStub<ProjectSandbox>> {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,15 @@ export const ProjectRestoreBackupInputSchema = z
234234
})
235235
.strict();
236236

237+
// Per-project teardown inside the shared per-user sandbox: names ONE project's workspace folder
238+
// (/workspace/<workspaceSlug>) whose dev server, port, and folder should be reclaimed — without
239+
// ever touching the shared sandbox itself.
240+
export const ProjectCleanupWorkspaceInputSchema = z
241+
.object({
242+
workspaceSlug: z.string().min(1).max(200),
243+
})
244+
.strict();
245+
237246
export type ProjectExecInput = z.input<typeof ProjectExecInputSchema>;
238247
export type ProjectStartProcessInput = z.input<typeof ProjectStartProcessInputSchema>;
239248
export type ProjectPreviewFileInput = z.input<typeof ProjectPreviewFileInputSchema>;
@@ -269,6 +278,7 @@ export interface ProjectSandboxRuntimeState {
269278
}
270279
export type ProjectCreateBackupInput = z.input<typeof ProjectCreateBackupInputSchema>;
271280
export type ProjectRestoreBackupInput = z.input<typeof ProjectRestoreBackupInputSchema>;
281+
export type ProjectCleanupWorkspaceInput = z.input<typeof ProjectCleanupWorkspaceInputSchema>;
272282

273283
export interface NormalizedRunCodeResult {
274284
stdout: string;

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

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DurableObject } from "cloudflare:workers";
22
import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env";
3-
import { APIError, normalizeUnknownError } from "@cheatcode/observability";
3+
import { APIError, createLogger, normalizeUnknownError } from "@cheatcode/observability";
44
import {
55
DaytonaApiError,
66
DaytonaClient,
@@ -36,6 +36,8 @@ import {
3636
commandToShellString,
3737
type ProjectAllocatePortInput,
3838
ProjectAllocatePortInputSchema,
39+
type ProjectCleanupWorkspaceInput,
40+
ProjectCleanupWorkspaceInputSchema,
3941
type ProjectCodeServerInput,
4042
ProjectCodeServerInputSchema,
4143
type ProjectCreateBackupInput,
@@ -879,6 +881,62 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
879881
this.startedVerifiedAtMs = 0;
880882
}
881883

884+
// Best-effort teardown of ONE project's footprint inside the shared per-user sandbox: kills the
885+
// project's dev server, frees its port allocation, and removes its /workspace/<slug> folder. It
886+
// deliberately never destroys the sandbox or wipes DO state (that would nuke the user's OTHER
887+
// projects). Project deletion must not fail on cleanup, so every step is catch-and-log.
888+
public async cleanupProjectWorkspace(input: ProjectCleanupWorkspaceInput): Promise<void> {
889+
try {
890+
const { workspaceSlug } = ProjectCleanupWorkspaceInputSchema.parse(input);
891+
const id = await this.existingSandboxId();
892+
if (!id) {
893+
// Nothing provisioned — no dev server, port, or folder to reclaim.
894+
return;
895+
}
896+
const slot = `${APP_PREVIEW_SLOT_PREFIX}${workspaceSlug}`;
897+
const port = (await this.portAllocation()).ports[workspaceSlug];
898+
await this.deleteProcessRecord(id, slot);
899+
if (port !== undefined) {
900+
await this.deleteProcessesOnPort(id, port, slot);
901+
await this.unexposePort({ port });
902+
}
903+
await this.freeProjectPort(workspaceSlug);
904+
await this.removeWorkspaceFolder(id, workspaceSlug);
905+
} catch (error) {
906+
createLogger().warn("project_workspace_cleanup_failed", {
907+
error: error instanceof Error ? error.message : "Unknown cleanup error",
908+
});
909+
}
910+
}
911+
912+
// Drop a project's dev-server port from the DO allocation table. webNext/mobileNext are left as-is
913+
// so freed ports are never recycled — a rebuilt project always takes the next fresh port.
914+
private async freeProjectPort(workspaceSlug: string): Promise<void> {
915+
const alloc = await this.portAllocation();
916+
if (alloc.ports[workspaceSlug] === undefined) {
917+
return;
918+
}
919+
const ports = Object.fromEntries(
920+
Object.entries(alloc.ports).filter(([slug]) => slug !== workspaceSlug),
921+
);
922+
await this.ctx.storage.put(PORT_ALLOC_KEY, { ...alloc, ports });
923+
}
924+
925+
// Best-effort `rm -rf` of a single project's folder. Guarded so the target is always a non-empty
926+
// child of /workspace and can never resolve to /workspace itself or escape it.
927+
private async removeWorkspaceFolder(id: string, workspaceSlug: string): Promise<void> {
928+
if (!isSingleWorkspaceSegment(workspaceSlug)) {
929+
return;
930+
}
931+
const path = `${WORKSPACE_DIR}/${workspaceSlug}`;
932+
await this.client()
933+
.execute(id, {
934+
command: `rm -rf ${shellQuote(path)}`,
935+
timeout: timeoutSeconds(DEFAULT_EXEC_TIMEOUT_MS),
936+
})
937+
.catch(() => undefined);
938+
}
939+
882940
// ----- internals -----
883941

884942
private client(): DaytonaClient {
@@ -1417,6 +1475,12 @@ function shellQuote(arg: string): string {
14171475
return `'${arg.replaceAll("'", "'\\''")}'`;
14181476
}
14191477

1478+
// A workspace slug must be a single path segment so `/workspace/<slug>` cannot escape /workspace
1479+
// or resolve to /workspace itself (rm -rf guard).
1480+
function isSingleWorkspaceSegment(slug: string): boolean {
1481+
return slug.length > 0 && !slug.includes("/") && slug !== "." && slug !== "..";
1482+
}
1483+
14201484
function lowercaseExtension(path: string): string {
14211485
const filename = basename(path).toLowerCase();
14221486
const dot = filename.lastIndexOf(".");

apps/agent-worker/src/index.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
runForRoute,
5353
sandboxForProject,
5454
sandboxForThread,
55+
sandboxForUser,
5556
saveTakeoverState,
5657
startAgentRun,
5758
startLegacyThreadRun,
@@ -137,8 +138,22 @@ const SandboxFileListQuerySchema = z
137138
const SandboxReadEncodingQuerySchema = z.enum(["utf8", "base64"]).optional();
138139
const InternalUserStateDeleteBodySchema = z
139140
.object({
140-
projectIds: z.array(z.string().uuid()).max(1_000),
141+
// Per project: id, plus the workspace slug so per-project cleanup can target /workspace/<slug>
142+
// in the shared per-user sandbox. `scope` distinguishes the two callers:
143+
// - "project": one project deleted → reclaim ONLY its folder; never destroy the sandbox.
144+
// - "account": user deleted → tear the whole per-user sandbox down exactly once.
145+
projects: z
146+
.array(
147+
z
148+
.object({
149+
id: z.string().uuid(),
150+
workspaceSlug: z.string().min(1).max(200).optional(),
151+
})
152+
.strict(),
153+
)
154+
.max(1_000),
141155
runIds: z.array(z.string().uuid()).max(10_000),
156+
scope: z.enum(["project", "account"]).default("account"),
142157
})
143158
.strict();
144159
const InternalUserStateDeleteResponseSchema = z
@@ -281,23 +296,28 @@ agentApp.post("/internal/users/:userId/delete-state", async (c) => {
281296
}
282297
}
283298

299+
// One sandbox per user: resolve it once. Account deletion tears it down; per-project deletion
300+
// only reclaims each project's own workspace so the user's OTHER projects survive.
301+
const sandbox = await sandboxForUser(c.env, userId);
284302
let projectStatesDeleted = 0;
285-
let projectVolumesDeleted = 0;
286-
for (const projectId of body.projectIds) {
287-
const sandbox = await sandboxForProject(c.env, userId, projectId);
303+
if (body.scope === "account") {
288304
await sandbox.destroySandbox();
289-
if (await sandbox.deleteProjectVolume()) {
290-
projectVolumesDeleted += 1;
291-
}
292305
await sandbox.deleteDurableState();
293-
projectStatesDeleted += 1;
306+
projectStatesDeleted = body.projects.length;
307+
} else {
308+
for (const project of body.projects) {
309+
if (project.workspaceSlug) {
310+
await sandbox.cleanupProjectWorkspace({ workspaceSlug: project.workspaceSlug });
311+
}
312+
projectStatesDeleted += 1;
313+
}
294314
}
295315

296316
return c.json(
297317
InternalUserStateDeleteResponseSchema.parse({
298318
ok: true,
299319
projectStatesDeleted,
300-
projectVolumesDeleted,
320+
projectVolumesDeleted: 0,
301321
runStatesDeleted,
302322
}),
303323
);

apps/gateway-worker/src/project-routes.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,28 @@ export async function deleteProjectRoute(
163163
projectId: ProjectIdType,
164164
userId: UserId,
165165
): Promise<Response> {
166-
const cleanupBody = JSON.stringify({ projectIds: [projectId], runIds: [] });
167-
const cleanupHeaders = await internalMaintenanceHeaders(env, cleanupBody);
168166
const { db, close } = createDb(env.HYPERDRIVE);
169167
try {
168+
// Read the workspace slug BEFORE soft-deleting (getProject filters deleted rows out) so the
169+
// agent worker can reclaim ONLY this project's /workspace/<slug> folder — scope "project"
170+
// guarantees it never destroys the shared per-user sandbox and the user's other projects.
171+
const project = await withUserContext(db, userId, (tx) =>
172+
getProject(tx, { projectId, userId }),
173+
);
174+
if (!project) {
175+
throw notFound("Project not found");
176+
}
177+
const cleanupBody = JSON.stringify({
178+
projects: [
179+
{
180+
id: projectId,
181+
...(project.workspaceSlug ? { workspaceSlug: project.workspaceSlug } : {}),
182+
},
183+
],
184+
runIds: [],
185+
scope: "project",
186+
});
187+
const cleanupHeaders = await internalMaintenanceHeaders(env, cleanupBody);
170188
const deleted = await withUserContext(db, userId, (tx) =>
171189
softDeleteProject(tx, { projectId, userId }),
172190
);

apps/web/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/webhooks-worker/src/lifecycle-adapters.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ async function deleteAgentDurableState(
141141
return { projectStatesDeleted, projectVolumesDeleted: 0, runStatesDeleted: 0 };
142142
}
143143
const body = JSON.stringify({
144-
projectIds: manifest.projectIds,
144+
projects: manifest.projectIds.map((id) => ({ id })),
145145
runIds: manifest.runIds,
146+
scope: "account",
146147
});
147148
const headers = await internalMaintenanceHeaders(env, body);
148149
const response = await env.AGENT.fetch(

0 commit comments

Comments
 (0)