1- import { db } from '@sim/db'
2- import { workspaceSandbox } from '@sim/db/schema'
31import { createLogger } from '@sim/logger'
4- import { and , eq } from 'drizzle-orm'
52import { type NextRequest , NextResponse } from 'next/server'
63import { deleteSandboxContract , updateSandboxContract } from '@/lib/api/contracts/sandboxes'
74import { parseRequest } from '@/lib/api/server'
8- import { runDetached } from '@/lib/core/utils/background'
95import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10- import { releaseSandboxImage } from '@/lib/execution/remote-sandbox/image-registry'
11- import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve'
126import {
13- isSandboxNameTaken ,
14- readWorkspaceSandbox ,
15- scheduleSandboxBuild ,
7+ deleteWorkspaceSandbox ,
8+ updateWorkspaceSandbox ,
169} from '@/lib/execution/remote-sandbox/workspace-sandboxes'
1710import {
1811 authorizeSandboxMutation ,
19- buildSpecOrResponse ,
20- isNameConflictError ,
21- nameConflictResponse ,
12+ sandboxMutationErrorResponse ,
2213} from '@/app/api/workspaces/[id]/sandboxes/authorize'
2314
2415const logger = createLogger ( 'WorkspaceSandboxAPI' )
@@ -33,90 +24,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Sand
3324
3425 const parsed = await parseRequest ( updateSandboxContract , request , context )
3526 if ( ! parsed . success ) return parsed . response
36- const { name, language, dependencies, cliTools, systemPackages } = parsed . data . body
37-
38- const [ existing ] = await db
39- . select ( {
40- id : workspaceSandbox . id ,
41- name : workspaceSandbox . name ,
42- language : workspaceSandbox . language ,
43- dependencies : workspaceSandbox . dependencies ,
44- cliTools : workspaceSandbox . cliTools ,
45- systemPackages : workspaceSandbox . systemPackages ,
46- specHash : workspaceSandbox . specHash ,
47- } )
48- . from ( workspaceSandbox )
49- . where ( and ( eq ( workspaceSandbox . id , sandboxId ) , eq ( workspaceSandbox . workspaceId , workspaceId ) ) )
50- . limit ( 1 )
51-
52- if ( ! existing ) {
53- return NextResponse . json ( { error : 'Sandbox not found' } , { status : 404 } )
54- }
55-
56- const nextName = name ?? existing . name
57- if ( name && name !== existing . name && ( await isSandboxNameTaken ( workspaceId , name , sandboxId ) ) ) {
58- return nameConflictResponse ( name )
59- }
60-
61- // The complete spec is revalidated even when only one field changed: switching
62- // language has to re-check the existing list against the new language's rules,
63- // and editing dependencies has to check them against the stored language.
64- const nextLanguage = language ?? ( existing . language as 'javascript' | 'python' )
65- const nextDependencies = dependencies ?? existing . dependencies ?? [ ]
66- const nextCliTools = cliTools ?? existing . cliTools ?? [ ]
67- const nextSystemPackages = systemPackages ?? existing . systemPackages ?? [ ]
68-
69- const built = buildSpecOrResponse (
70- nextLanguage ,
71- nextDependencies ,
72- nextCliTools ,
73- nextSystemPackages
74- )
75- if ( ! built . ok ) return built . response
76- const { spec } = built
77-
7827 try {
79- await db
80- . update ( workspaceSandbox )
81- . set ( {
82- name : nextName ,
83- language : spec . language ,
84- dependencies : spec . dependencies ,
85- cliTools : spec . cliTools ,
86- systemPackages : spec . systemPackages ,
87- specHash : spec . specHash ,
88- updatedAt : new Date ( ) ,
89- } )
90- // Scoped by workspace as well as id: every other query here is, and relying on
91- // the SELECT above to have 404'd first makes authz an ordering invariant.
92- . where ( and ( eq ( workspaceSandbox . id , sandboxId ) , eq ( workspaceSandbox . workspaceId , workspaceId ) ) )
28+ const sandbox = await updateWorkspaceSandbox ( workspaceId , sandboxId , parsed . data . body )
29+ logger . info ( 'Updated workspace sandbox' , { workspaceId, sandboxId } )
30+ return NextResponse . json ( { sandbox } )
9331 } catch ( error ) {
94- // The pre-check above can lose a race with a concurrent rename; the unique
95- // index is the real arbiter, and losing it is a conflict, not a server fault.
96- if ( isNameConflictError ( error ) ) return nameConflictResponse ( nextName )
32+ const response = sandboxMutationErrorResponse ( error )
33+ if ( response ) return response
9734 throw error
9835 }
99-
100- // Unconditional, because the registry decides what a save costs: a `ready` or
101- // in-flight row is left alone, so renaming or re-saving an unchanged spec
102- // enqueues nothing, while a failed one gets the immediate retry a person saving
103- // is asking for. Gating this on a changed hash meant a same-spec save silently
104- // did nothing, and the only way to retry a failed build was to edit the package
105- // list into a different hash.
106- await scheduleSandboxBuild ( spec )
107-
108- if ( spec . specHash !== existing . specHash ) {
109- // The previous content address is unreferenced by this sandbox now. Release
110- // no-ops when another sandbox still declares the same package list.
111- runDetached ( 'release-sandbox-image' , ( ) => releaseSandboxImage ( existing . specHash ) )
112- logger . info ( 'Sandbox spec changed, scheduled a build' , { workspaceId, sandboxId } )
113- }
114-
115- const sandbox = await readWorkspaceSandbox ( workspaceId , sandboxId )
116- if ( ! sandbox ) {
117- return NextResponse . json ( { error : 'Failed to read back the updated sandbox' } , { status : 500 } )
118- }
119- return NextResponse . json ( { sandbox } )
12036} )
12137
12238export const DELETE = withRouteHandler ( async ( request : NextRequest , context : SandboxContext ) => {
@@ -128,23 +44,14 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: San
12844 const parsed = await parseRequest ( deleteSandboxContract , request , context )
12945 if ( ! parsed . success ) return parsed . response
13046
131- // A block may still reference this sandbox. Deleting is allowed anyway; that
132- // execution then fails closed with a message naming the missing sandbox,
133- // rather than silently falling back to an image without its dependencies.
134- const deleted = await db
135- . delete ( workspaceSandbox )
136- . where ( and ( eq ( workspaceSandbox . id , sandboxId ) , eq ( workspaceSandbox . workspaceId , workspaceId ) ) )
137- . returning ( { id : workspaceSandbox . id , specHash : workspaceSandbox . specHash } )
138-
139- if ( deleted . length === 0 ) {
140- return NextResponse . json ( { error : 'Sandbox not found' } , { status : 404 } )
47+ try {
48+ await deleteWorkspaceSandbox ( workspaceId , sandboxId )
49+ } catch ( error ) {
50+ const response = sandboxMutationErrorResponse ( error )
51+ if ( response ) return response
52+ throw error
14153 }
14254
143- invalidateSandboxResolution ( )
144- // Detached: the row is already gone, so the caller's delete succeeded whatever
145- // the provider says. Awaiting would hold a UI delete open on a remote call the
146- // retention sweep would retry anyway.
147- runDetached ( 'release-sandbox-image' , ( ) => releaseSandboxImage ( deleted [ 0 ] . specHash ) )
14855 logger . info ( 'Deleted workspace sandbox' , { workspaceId, sandboxId } )
14956 return NextResponse . json ( { success : true } )
15057} )
0 commit comments