@yolk-sdk/sandbox 0.1.0-canary.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/agent.d.mts +48 -0
  4. package/dist/agent.d.mts.map +1 -0
  5. package/dist/agent.mjs +173 -0
  6. package/dist/agent.mjs.map +1 -0
  7. package/dist/errors.d.mts +44 -0
  8. package/dist/errors.d.mts.map +1 -0
  9. package/dist/errors.mjs +40 -0
  10. package/dist/errors.mjs.map +1 -0
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.mjs +7 -0
  13. package/dist/lifecycle.d.mts +52 -0
  14. package/dist/lifecycle.d.mts.map +1 -0
  15. package/dist/lifecycle.mjs +118 -0
  16. package/dist/lifecycle.mjs.map +1 -0
  17. package/dist/model.d.mts +84 -0
  18. package/dist/model.d.mts.map +1 -0
  19. package/dist/model.mjs +72 -0
  20. package/dist/model.mjs.map +1 -0
  21. package/dist/name.d.mts +5 -0
  22. package/dist/name.d.mts.map +1 -0
  23. package/dist/name.mjs +19 -0
  24. package/dist/name.mjs.map +1 -0
  25. package/dist/service.d.mts +15 -0
  26. package/dist/service.d.mts.map +1 -0
  27. package/dist/service.mjs +7 -0
  28. package/dist/service.mjs.map +1 -0
  29. package/dist/state-store.d.mts +18 -0
  30. package/dist/state-store.d.mts.map +1 -0
  31. package/dist/state-store.mjs +7 -0
  32. package/dist/state-store.mjs.map +1 -0
  33. package/dist/testing/index.d.mts +21 -0
  34. package/dist/testing/index.d.mts.map +1 -0
  35. package/dist/testing/index.mjs +25 -0
  36. package/dist/testing/index.mjs.map +1 -0
  37. package/dist/vercel/client.d.mts +70 -0
  38. package/dist/vercel/client.d.mts.map +1 -0
  39. package/dist/vercel/client.mjs +113 -0
  40. package/dist/vercel/client.mjs.map +1 -0
  41. package/dist/vercel/index.d.mts +3 -0
  42. package/dist/vercel/index.mjs +3 -0
  43. package/dist/vercel/layer.d.mts +23 -0
  44. package/dist/vercel/layer.d.mts.map +1 -0
  45. package/dist/vercel/layer.mjs +250 -0
  46. package/dist/vercel/layer.mjs.map +1 -0
  47. package/package.json +72 -0
  48. package/src/agent.ts +304 -0
  49. package/src/errors.ts +71 -0
  50. package/src/index.ts +52 -0
  51. package/src/lifecycle.ts +186 -0
  52. package/src/model.ts +122 -0
  53. package/src/name.ts +23 -0
  54. package/src/service.ts +19 -0
  55. package/src/state-store.ts +17 -0
  56. package/src/testing/index.ts +63 -0
  57. package/src/vercel/client.ts +223 -0
  58. package/src/vercel/index.ts +24 -0
  59. package/src/vercel/layer.ts +396 -0
@@ -0,0 +1,223 @@
1
+ import { Context, Layer } from 'effect'
2
+ import { APIError, Sandbox as VercelSdkSandbox, type Command, type CommandFinished, type NetworkPolicy } from '@vercel/sandbox'
3
+ import type { SandboxInitialSource, SandboxResources, SandboxSnapshotRetention } from '../model.ts'
4
+
5
+ export type VercelSandboxRuntime = 'node22' | 'node24' | 'node26' | 'python3.13'
6
+ export type VercelNetworkPolicy = NetworkPolicy
7
+
8
+ export type VercelCommandOutput = {
9
+ readonly stdout: string
10
+ readonly stderr: string
11
+ }
12
+
13
+ export type VercelCommandSignal = 'SIGTERM' | 'SIGKILL'
14
+
15
+ export type VercelFinishedCommand = {
16
+ readonly id: string
17
+ readonly exitCode: number
18
+ readonly output: () => Promise<VercelCommandOutput>
19
+ }
20
+
21
+ export type VercelDetachedCommand = {
22
+ readonly id: string
23
+ readonly exitCode: number | null
24
+ readonly wait: () => Promise<VercelFinishedCommand>
25
+ readonly kill: (signal: VercelCommandSignal) => Promise<void>
26
+ readonly output: () => Promise<VercelCommandOutput>
27
+ }
28
+
29
+ export type VercelSandboxFile = {
30
+ readonly path: string
31
+ readonly content: string
32
+ readonly mode?: number
33
+ }
34
+
35
+ export type VercelRunCommandInput = {
36
+ readonly cmd: string
37
+ readonly args?: ReadonlyArray<string>
38
+ readonly cwd?: string
39
+ }
40
+
41
+ export type VercelSandboxCreateInput = {
42
+ readonly name: string
43
+ readonly source: SandboxInitialSource
44
+ readonly ports: ReadonlyArray<number>
45
+ readonly timeoutMs: number
46
+ readonly resources: SandboxResources
47
+ readonly runtime: VercelSandboxRuntime
48
+ readonly env?: Readonly<Record<string, string>>
49
+ readonly persistent: boolean
50
+ readonly snapshotExpirationMs?: number
51
+ readonly keepLastSnapshots?: SandboxSnapshotRetention
52
+ readonly networkPolicy?: VercelNetworkPolicy
53
+ }
54
+
55
+ export type VercelSandboxGetInput = {
56
+ readonly name: string
57
+ }
58
+
59
+ export type VercelSandboxHandle = {
60
+ readonly name: string
61
+ readonly writeFiles: (files: ReadonlyArray<VercelSandboxFile>) => Promise<void>
62
+ readonly runDetachedCommand: (input: VercelRunCommandInput) => Promise<VercelDetachedCommand>
63
+ readonly getCommand: (id: string) => Promise<VercelDetachedCommand>
64
+ readonly extendTimeout: (durationMs: number) => Promise<void>
65
+ readonly delete: () => Promise<void>
66
+ readonly domain: (port: number) => string
67
+ }
68
+
69
+ export type VercelSandboxClientApi = {
70
+ readonly get: (input: VercelSandboxGetInput) => Promise<VercelSandboxHandle | null>
71
+ readonly create: (input: VercelSandboxCreateInput) => Promise<VercelSandboxHandle>
72
+ }
73
+
74
+ export class VercelSandboxClient extends Context.Service<VercelSandboxClient, VercelSandboxClientApi>()(
75
+ '@yolk-sdk/sandbox/vercel/VercelSandboxClient'
76
+ ) {}
77
+
78
+ export const isVercelMissingSandboxError = (error: unknown) =>
79
+ error instanceof APIError && (error.response.status === 404 || error.response.status === 410)
80
+
81
+ const commandOutput = (command: Command | CommandFinished): Promise<VercelCommandOutput> =>
82
+ Promise.all([command.stdout(), command.stderr()]).then(([stdout, stderr]) => ({ stdout, stderr }))
83
+
84
+ const toFinishedCommand = (command: CommandFinished): VercelFinishedCommand => ({
85
+ id: command.cmdId,
86
+ exitCode: command.exitCode,
87
+ output: () => commandOutput(command)
88
+ })
89
+
90
+ const toDetachedCommand = (command: Command): VercelDetachedCommand => ({
91
+ id: command.cmdId,
92
+ exitCode: command.exitCode,
93
+ wait: () => command.wait().then(toFinishedCommand),
94
+ kill: signal => command.kill(signal),
95
+ output: () => commandOutput(command)
96
+ })
97
+
98
+ const toHandle = (sandbox: VercelSdkSandbox): VercelSandboxHandle => ({
99
+ name: sandbox.name,
100
+ writeFiles: files =>
101
+ sandbox.writeFiles(files.map(file => ({
102
+ path: file.path,
103
+ content: file.content,
104
+ ...(file.mode === undefined ? {} : { mode: file.mode })
105
+ }))),
106
+ runDetachedCommand: input =>
107
+ sandbox.runCommand({
108
+ cmd: input.cmd,
109
+ args: input.args === undefined ? [] : [...input.args],
110
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
111
+ detached: true
112
+ }).then(toDetachedCommand),
113
+ getCommand: id => sandbox.getCommand(id).then(toDetachedCommand),
114
+ extendTimeout: durationMs => sandbox.extendTimeout(durationMs).then(() => undefined),
115
+ delete: () => sandbox.delete().then(() => undefined),
116
+ domain: port => sandbox.domain(port)
117
+ })
118
+
119
+ const envObject = (env?: Readonly<Record<string, string>>) =>
120
+ env === undefined ? undefined : Object.fromEntries(Object.entries(env))
121
+
122
+ const retentionObject = (retention?: SandboxSnapshotRetention) =>
123
+ retention === undefined
124
+ ? undefined
125
+ : {
126
+ count: retention.count,
127
+ ...(retention.expirationMs === undefined ? {} : { expiration: retention.expirationMs }),
128
+ ...(retention.deleteEvicted === undefined ? {} : { deleteEvicted: retention.deleteEvicted })
129
+ }
130
+
131
+ type AnonymousGitSource = {
132
+ readonly type: 'git'
133
+ readonly url: string
134
+ readonly depth?: number
135
+ readonly revision?: string
136
+ }
137
+
138
+ type BasicAuthGitSource = AnonymousGitSource & {
139
+ readonly username: string
140
+ readonly password: string
141
+ }
142
+
143
+ const commonCreateOptions = (input: VercelSandboxCreateInput) => ({
144
+ name: input.name,
145
+ ports: [...input.ports],
146
+ timeout: input.timeoutMs,
147
+ resources: { vcpus: input.resources.vcpus },
148
+ env: envObject(input.env),
149
+ persistent: input.persistent,
150
+ networkPolicy: input.networkPolicy,
151
+ snapshotExpiration: input.snapshotExpirationMs,
152
+ keepLastSnapshots: retentionObject(input.keepLastSnapshots)
153
+ })
154
+
155
+ const gitSource = (
156
+ source: Extract<SandboxInitialSource, { readonly _tag: 'Git' }>
157
+ ): AnonymousGitSource | BasicAuthGitSource => {
158
+ if ((source.username === undefined) !== (source.password === undefined)) {
159
+ throw new Error('Vercel git source requires both username and password when using basic auth')
160
+ }
161
+
162
+ if (source.username !== undefined && source.password !== undefined) {
163
+ return {
164
+ type: 'git',
165
+ url: source.url,
166
+ username: source.username,
167
+ password: source.password,
168
+ ...(source.depth === undefined ? {} : { depth: source.depth }),
169
+ ...(source.revision === undefined ? {} : { revision: source.revision })
170
+ }
171
+ }
172
+
173
+ return {
174
+ type: 'git',
175
+ url: source.url,
176
+ ...(source.depth === undefined ? {} : { depth: source.depth }),
177
+ ...(source.revision === undefined ? {} : { revision: source.revision })
178
+ }
179
+ }
180
+
181
+ const createSandbox = (input: VercelSandboxCreateInput) => {
182
+ const common = commonCreateOptions(input)
183
+
184
+ switch (input.source._tag) {
185
+ case 'Empty':
186
+ return VercelSdkSandbox.create({
187
+ ...common,
188
+ runtime: input.runtime
189
+ })
190
+ case 'Snapshot':
191
+ return VercelSdkSandbox.create({
192
+ ...common,
193
+ source: { type: 'snapshot', snapshotId: input.source.snapshotId }
194
+ })
195
+ case 'Git':
196
+ return VercelSdkSandbox.create({
197
+ ...common,
198
+ runtime: input.runtime,
199
+ source: gitSource(input.source)
200
+ })
201
+ case 'Tarball':
202
+ return VercelSdkSandbox.create({
203
+ ...common,
204
+ runtime: input.runtime,
205
+ source: { type: 'tarball', url: input.source.url }
206
+ })
207
+ }
208
+ }
209
+
210
+ export const VercelSandboxClientLive = Layer.succeed(
211
+ VercelSandboxClient,
212
+ VercelSandboxClient.of({
213
+ get: input =>
214
+ VercelSdkSandbox.get({ name: input.name }).then(toHandle).catch(error => {
215
+ if (isVercelMissingSandboxError(error)) {
216
+ return null
217
+ }
218
+
219
+ throw error
220
+ }),
221
+ create: input => createSandbox(input).then(toHandle)
222
+ })
223
+ )
@@ -0,0 +1,24 @@
1
+ export {
2
+ isVercelMissingSandboxError,
3
+ VercelSandboxClient,
4
+ VercelSandboxClientLive
5
+ } from './client.ts'
6
+ export {
7
+ makeVercelSandboxLayer,
8
+ makeVercelSandboxLayerWithClient
9
+ } from './layer.ts'
10
+ export type {
11
+ VercelCommandOutput,
12
+ VercelCommandSignal,
13
+ VercelDetachedCommand,
14
+ VercelFinishedCommand,
15
+ VercelNetworkPolicy,
16
+ VercelRunCommandInput,
17
+ VercelSandboxClientApi,
18
+ VercelSandboxCreateInput,
19
+ VercelSandboxFile,
20
+ VercelSandboxGetInput,
21
+ VercelSandboxHandle,
22
+ VercelSandboxRuntime
23
+ } from './client.ts'
24
+ export type { VercelSandboxLayerConfig } from './layer.ts'
@@ -0,0 +1,396 @@
1
+ import { Array as Arr, Clock, Duration, Effect, Layer, Option, Ref, Semaphore } from 'effect'
2
+ import {
3
+ absoluteSandboxCwd,
4
+ backgroundSandboxProbeMs,
5
+ defaultSandboxPorts,
6
+ defaultSandboxWorkspaceRoot,
7
+ initialSandboxState,
8
+ normalizeWorkspaceCwd,
9
+ sandboxCommandTimeoutMs,
10
+ sandboxLifecycleOrDefault,
11
+ sandboxSourceOrDefault,
12
+ sandboxStateDecision,
13
+ sandboxTimeoutExtendDeltaMs,
14
+ touchSandboxState,
15
+ validateSandboxCommand
16
+ } from '../lifecycle.ts'
17
+ import { makeVercelSandboxName } from '../name.ts'
18
+ import {
19
+ SandboxCommandResult,
20
+ type SandboxCommandInput,
21
+ SandboxPreviewUrl,
22
+ SandboxResources,
23
+ type SandboxInitialSource,
24
+ type SandboxLifecycle,
25
+ type SandboxState
26
+ } from '../model.ts'
27
+ import { Sandbox } from '../service.ts'
28
+ import { SandboxStateStore } from '../state-store.ts'
29
+ import { SandboxProviderError, unknownToMessage } from '../errors.ts'
30
+ import type { SandboxError } from '../errors.ts'
31
+ import {
32
+ VercelSandboxClient,
33
+ VercelSandboxClientLive,
34
+ type VercelDetachedCommand,
35
+ type VercelNetworkPolicy,
36
+ type VercelSandboxHandle,
37
+ type VercelSandboxRuntime
38
+ } from './client.ts'
39
+
40
+ export type VercelSandboxLayerConfig = {
41
+ readonly sandboxSessionId: string
42
+ readonly lifecycle?: SandboxLifecycle
43
+ readonly source?: SandboxInitialSource
44
+ readonly env?: Readonly<Record<string, string>>
45
+ readonly ports?: ReadonlyArray<number>
46
+ readonly resources?: SandboxResources
47
+ readonly runtime?: VercelSandboxRuntime
48
+ readonly networkPolicy?: VercelNetworkPolicy
49
+ readonly workspaceRoot?: string
50
+ }
51
+
52
+ type ActiveSandbox = {
53
+ readonly handle: VercelSandboxHandle
54
+ readonly state: SandboxState
55
+ readonly workspaceReset: boolean
56
+ }
57
+
58
+ type CommandRunOutput = {
59
+ readonly exitCode: number | null
60
+ readonly stdout: string
61
+ readonly stderr: string
62
+ readonly timedOut: boolean
63
+ readonly backgroundId?: string
64
+ }
65
+
66
+ const defaultVercelResources = SandboxResources.make({ vcpus: 2 })
67
+ const defaultVercelRuntime: VercelSandboxRuntime = 'node24'
68
+
69
+ const providerError = (operation: string, error: unknown) =>
70
+ new SandboxProviderError({
71
+ provider: 'vercel',
72
+ operation,
73
+ message: `Vercel Sandbox ${operation} failed: ${unknownToMessage(error)}`,
74
+ underlying: error
75
+ })
76
+
77
+ const tryProvider = <A>(operation: string, body: () => Promise<A>) =>
78
+ Effect.tryPromise({
79
+ try: body,
80
+ catch: error => providerError(operation, error)
81
+ })
82
+
83
+ const lifecycleTimeoutMs = (lifecycle: SandboxLifecycle) => {
84
+ switch (lifecycle._tag) {
85
+ case 'Disposable':
86
+ return Math.min(lifecycle.idleTtlMs, lifecycle.maxLifetimeMs)
87
+ case 'Persistent':
88
+ return lifecycle.idleTtlMs
89
+ }
90
+ }
91
+
92
+ const persistentFlag = (lifecycle: SandboxLifecycle) => lifecycle._tag === 'Persistent'
93
+
94
+ const snapshotExpirationMs = (lifecycle: SandboxLifecycle) =>
95
+ lifecycle._tag === 'Persistent' ? lifecycle.snapshotExpirationMs : undefined
96
+
97
+ const keepLastSnapshots = (lifecycle: SandboxLifecycle) =>
98
+ lifecycle._tag === 'Persistent' ? lifecycle.keepLastSnapshots : undefined
99
+
100
+ const previewUrls = (handle: VercelSandboxHandle, ports: ReadonlyArray<number>) =>
101
+ Effect.forEach(ports, port =>
102
+ Effect.try({
103
+ try: () => handle.domain(port),
104
+ catch: error => error
105
+ }).pipe(
106
+ Effect.match({
107
+ onFailure: () => Option.none<SandboxPreviewUrl>(),
108
+ onSuccess: url => Option.some(SandboxPreviewUrl.make({ port, url }))
109
+ })
110
+ )
111
+ ).pipe(Effect.map(Arr.getSomes))
112
+
113
+ const commandOutputOrEmpty = (command: VercelDetachedCommand) =>
114
+ tryProvider('command.output', command.output).pipe(
115
+ Effect.catchTag('SandboxProviderError', error =>
116
+ Effect.succeed({
117
+ stdout: '',
118
+ stderr: error.message
119
+ })
120
+ )
121
+ )
122
+
123
+ const finishedCommandOutputOrEmpty = (command: {
124
+ readonly output: () => Promise<{ readonly stdout: string; readonly stderr: string }>
125
+ }) =>
126
+ tryProvider('command.output', command.output).pipe(
127
+ Effect.catchTag('SandboxProviderError', error =>
128
+ Effect.succeed({
129
+ stdout: '',
130
+ stderr: error.message
131
+ })
132
+ )
133
+ )
134
+
135
+ const waitForegroundCommand = (
136
+ command: VercelDetachedCommand,
137
+ timeoutMs: number
138
+ ): Effect.Effect<CommandRunOutput, SandboxProviderError> =>
139
+ tryProvider('command.wait', command.wait).pipe(
140
+ Effect.flatMap(finished =>
141
+ finishedCommandOutputOrEmpty(finished).pipe(
142
+ Effect.map(output => ({
143
+ exitCode: finished.exitCode,
144
+ stdout: output.stdout,
145
+ stderr: output.stderr,
146
+ timedOut: false
147
+ }))
148
+ )
149
+ ),
150
+ Effect.timeoutOrElse({
151
+ duration: Duration.millis(timeoutMs),
152
+ orElse: () =>
153
+ Effect.gen(function* () {
154
+ yield* tryProvider('command.kill', () => command.kill('SIGKILL')).pipe(
155
+ Effect.catchTag('SandboxProviderError', () => Effect.void)
156
+ )
157
+ const output = yield* commandOutputOrEmpty(command)
158
+
159
+ return {
160
+ exitCode: null,
161
+ stdout: output.stdout,
162
+ stderr: output.stderr,
163
+ timedOut: true
164
+ }
165
+ })
166
+ })
167
+ )
168
+
169
+ const probeBackgroundCommand = (
170
+ handle: VercelSandboxHandle,
171
+ command: VercelDetachedCommand
172
+ ): Effect.Effect<CommandRunOutput, SandboxProviderError> =>
173
+ Effect.sleep(Duration.millis(backgroundSandboxProbeMs)).pipe(
174
+ Effect.flatMap(() => tryProvider('command.get', () => handle.getCommand(command.id))),
175
+ Effect.flatMap(current => {
176
+ if (current.exitCode === null) {
177
+ return Effect.succeed({
178
+ exitCode: null,
179
+ stdout: '',
180
+ stderr: '',
181
+ timedOut: false,
182
+ backgroundId: command.id
183
+ })
184
+ }
185
+
186
+ return commandOutputOrEmpty(current).pipe(
187
+ Effect.map(output => ({
188
+ exitCode: current.exitCode,
189
+ stdout: output.stdout,
190
+ stderr: output.stderr,
191
+ timedOut: false
192
+ }))
193
+ )
194
+ })
195
+ )
196
+
197
+ const createCommandFiles = (input: {
198
+ readonly commandId: string
199
+ readonly workspaceRoot: string
200
+ readonly command: string
201
+ readonly stdin?: string
202
+ }) => {
203
+ const commandDir = `${input.workspaceRoot}/.yolk/commands/${input.commandId}`
204
+ const commandPath = `${commandDir}/command.sh`
205
+ const stdinPath = `${commandDir}/stdin.txt`
206
+ const wrapperPath = `${commandDir}/wrapper.sh`
207
+
208
+ return {
209
+ wrapperPath,
210
+ files: [
211
+ {
212
+ path: commandPath,
213
+ content: `#!/usr/bin/env bash\n${input.command}\n`,
214
+ mode: 0o755
215
+ },
216
+ {
217
+ path: stdinPath,
218
+ content: input.stdin ?? ''
219
+ },
220
+ {
221
+ path: wrapperPath,
222
+ content: `#!/usr/bin/env bash\nbash ${JSON.stringify(commandPath)} < ${JSON.stringify(stdinPath)}\n`,
223
+ mode: 0o755
224
+ }
225
+ ]
226
+ }
227
+ }
228
+
229
+ export const makeVercelSandboxLayerWithClient = (config: VercelSandboxLayerConfig) =>
230
+ Layer.effect(
231
+ Sandbox,
232
+ Effect.gen(function* () {
233
+ const store = yield* SandboxStateStore
234
+ const client = yield* VercelSandboxClient
235
+ const semaphore = yield* Semaphore.make(1)
236
+ const commandCounter = yield* Ref.make(0)
237
+ const lifecycle = sandboxLifecycleOrDefault(config.lifecycle)
238
+ const source = sandboxSourceOrDefault(config.source)
239
+ const ports = config.ports ?? defaultSandboxPorts
240
+ const resources = config.resources ?? defaultVercelResources
241
+ const runtime = config.runtime ?? defaultVercelRuntime
242
+ const workspaceRoot = config.workspaceRoot ?? defaultSandboxWorkspaceRoot
243
+ const name = makeVercelSandboxName(config.sandboxSessionId)
244
+
245
+ const deleteNamedSandbox = (sandboxName: string) =>
246
+ tryProvider('get', () => client.get({ name: sandboxName })).pipe(
247
+ Effect.flatMap(handle =>
248
+ handle === null
249
+ ? Effect.void
250
+ : tryProvider('delete', () => handle.delete())
251
+ )
252
+ )
253
+
254
+ const createSandbox = (input: { readonly nowMs: number; readonly workspaceReset: boolean }) =>
255
+ Effect.gen(function* () {
256
+ const handle = yield* tryProvider('create', () =>
257
+ client.create({
258
+ name,
259
+ source,
260
+ ports,
261
+ timeoutMs: lifecycleTimeoutMs(lifecycle),
262
+ resources,
263
+ runtime,
264
+ env: config.env,
265
+ persistent: persistentFlag(lifecycle),
266
+ snapshotExpirationMs: snapshotExpirationMs(lifecycle),
267
+ keepLastSnapshots: keepLastSnapshots(lifecycle),
268
+ networkPolicy: config.networkPolicy
269
+ })
270
+ )
271
+ const state = initialSandboxState({ name, nowMs: input.nowMs, lifecycle })
272
+ yield* store.save({ sandboxSessionId: config.sandboxSessionId, state })
273
+
274
+ return { handle, state, workspaceReset: input.workspaceReset }
275
+ })
276
+
277
+ const touchExistingSandbox = (input: {
278
+ readonly handle: VercelSandboxHandle
279
+ readonly state: SandboxState
280
+ readonly nowMs: number
281
+ readonly workspaceReset: boolean
282
+ }) =>
283
+ Effect.gen(function* () {
284
+ const touched = touchSandboxState({
285
+ state: input.state,
286
+ nowMs: input.nowMs,
287
+ lifecycle
288
+ })
289
+ const deltaMs = sandboxTimeoutExtendDeltaMs({ before: input.state, after: touched })
290
+
291
+ if (deltaMs > 0) {
292
+ yield* tryProvider('extendTimeout', () => input.handle.extendTimeout(deltaMs))
293
+ }
294
+
295
+ yield* store.save({ sandboxSessionId: config.sandboxSessionId, state: touched })
296
+
297
+ return {
298
+ handle: input.handle,
299
+ state: touched,
300
+ workspaceReset: input.workspaceReset
301
+ }
302
+ })
303
+
304
+ const ensureSandbox = (nowMs: number): Effect.Effect<ActiveSandbox, SandboxError> =>
305
+ Effect.gen(function* () {
306
+ const loaded = yield* store.load(config.sandboxSessionId)
307
+ const decision = sandboxStateDecision({ state: loaded, name, nowMs })
308
+
309
+ if (decision._tag === 'Create') {
310
+ if (Option.isSome(loaded) && decision.workspaceReset) {
311
+ yield* deleteNamedSandbox(loaded.value.name)
312
+ yield* store.clear(config.sandboxSessionId)
313
+ }
314
+
315
+ return yield* createSandbox({ nowMs, workspaceReset: decision.workspaceReset })
316
+ }
317
+
318
+ const handle = yield* tryProvider('get', () => client.get({ name }))
319
+
320
+ if (handle === null) {
321
+ yield* store.clear(config.sandboxSessionId)
322
+ return yield* createSandbox({ nowMs, workspaceReset: true })
323
+ }
324
+
325
+ return yield* touchExistingSandbox({
326
+ handle,
327
+ state: decision.state,
328
+ nowMs,
329
+ workspaceReset: false
330
+ })
331
+ })
332
+
333
+ const nextCommandId = Ref.updateAndGet(commandCounter, value => value + 1).pipe(
334
+ Effect.map(value => `command-${value}`)
335
+ )
336
+
337
+ const runUnlocked = (input: SandboxCommandInput) =>
338
+ Effect.gen(function* () {
339
+ yield* validateSandboxCommand(input.command)
340
+ const normalizedCwd = yield* normalizeWorkspaceCwd(input.cwd)
341
+ const timeoutMs = sandboxCommandTimeoutMs(input.timeoutMs)
342
+ const nowMs = yield* Clock.currentTimeMillis
343
+ const active = yield* ensureSandbox(nowMs)
344
+ const id = yield* nextCommandId
345
+ const commandFiles = createCommandFiles({
346
+ commandId: id,
347
+ workspaceRoot,
348
+ command: input.command,
349
+ stdin: input.stdin
350
+ })
351
+
352
+ yield* tryProvider('writeFiles', () => active.handle.writeFiles(commandFiles.files))
353
+
354
+ const startedAtMs = yield* Clock.currentTimeMillis
355
+ const command = yield* tryProvider('runCommand', () =>
356
+ active.handle.runDetachedCommand({
357
+ cmd: 'bash',
358
+ args: [commandFiles.wrapperPath],
359
+ cwd: absoluteSandboxCwd(workspaceRoot, normalizedCwd)
360
+ })
361
+ )
362
+ const output = yield* (input.background === true
363
+ ? probeBackgroundCommand(active.handle, command)
364
+ : waitForegroundCommand(command, timeoutMs))
365
+ const endedAtMs = yield* Clock.currentTimeMillis
366
+ const urls = yield* previewUrls(active.handle, ports)
367
+
368
+ return SandboxCommandResult.make({
369
+ exitCode: output.exitCode,
370
+ stdout: output.stdout,
371
+ stderr: output.stderr,
372
+ durationMs: Math.max(0, endedAtMs - startedAtMs),
373
+ timedOut: output.timedOut,
374
+ workspaceReset: active.workspaceReset,
375
+ ...(output.backgroundId === undefined ? {} : { backgroundId: output.backgroundId }),
376
+ previewUrls: urls,
377
+ state: active.state
378
+ })
379
+ })
380
+
381
+ return Sandbox.of({
382
+ run: input => semaphore.withPermits(1)(runUnlocked(input)),
383
+ currentState: store.load(config.sandboxSessionId),
384
+ delete: Effect.gen(function* () {
385
+ const loaded = yield* store.load(config.sandboxSessionId)
386
+ if (Option.isSome(loaded)) {
387
+ yield* deleteNamedSandbox(loaded.value.name)
388
+ }
389
+ yield* store.clear(config.sandboxSessionId)
390
+ })
391
+ })
392
+ })
393
+ )
394
+
395
+ export const makeVercelSandboxLayer = (config: VercelSandboxLayerConfig) =>
396
+ makeVercelSandboxLayerWithClient(config).pipe(Layer.provide(VercelSandboxClientLive))