diff --git a/.changeset/workflow-serde-support.md b/.changeset/workflow-serde-support.md new file mode 100644 index 0000000..dab08f7 --- /dev/null +++ b/.changeset/workflow-serde-support.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Add Workflow DevKit serialization support. Sandbox, Command, CommandFinished, and Snapshot classes now implement `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` symbols from `@workflow/serde`, enabling instances to be passed across workflow/step serialization boundaries. All API-calling methods are annotated with `"use step"` for durable execution compatibility. diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 5bd272d..6497fe3 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -31,6 +31,7 @@ "license": "Apache-2.0", "dependencies": { "@vercel/oidc": "3.2.0", + "@workflow/serde": "^4.1.0-beta.2", "async-retry": "1.3.3", "jsonlines": "0.1.1", "ms": "2.1.3", diff --git a/packages/vercel-sandbox/src/command.ts b/packages/vercel-sandbox/src/command.ts index 99b5303..7203c86 100644 --- a/packages/vercel-sandbox/src/command.ts +++ b/packages/vercel-sandbox/src/command.ts @@ -1,5 +1,7 @@ import { APIClient, type CommandData } from "./api-client"; import { Signal, resolveSignal } from "./utils/resolveSignal"; +import { getCredentials } from "./utils/get-credentials"; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; /** * A command executed in a Sandbox. @@ -19,7 +21,7 @@ export class Command { * @internal * @private */ - protected client: APIClient; + protected _client: APIClient | null; /** * ID of the sandbox this command is running in. @@ -44,6 +46,52 @@ export class Command { both: string; }> | null = null; + /** + * Lazily resolve credentials and construct an API client. + * @internal + */ + protected async ensureClient(): Promise { + "use step"; + if (this._client) return this._client; + const credentials = await getCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + }); + return this._client; + } + + /** + * Serialize a Command instance for Workflow DevKit. + */ + static [WORKFLOW_SERIALIZE](instance: Command) { + return { + sandboxId: instance.sandboxId, + cmd: instance.cmd, + exitCode: instance.exitCode, + privateParams: instance.privateParams, + }; + } + + /** + * Deserialize a Command instance for Workflow DevKit. + */ + static [WORKFLOW_DESERIALIZE](data: { + sandboxId: string; + cmd: CommandData; + exitCode: number | null; + privateParams: Record; + }): Command { + const instance = Object.create(Command.prototype); + instance._client = null; + instance.sandboxId = data.sandboxId; + instance.cmd = data.cmd; + instance.exitCode = data.exitCode; + instance.privateParams = data.privateParams; + instance.outputCache = null; + return instance; + } + /** * ID of the command execution. */ @@ -72,12 +120,12 @@ export class Command { cmd, privateParams, }: { - client: APIClient; + client: APIClient | null; sandboxId: string; cmd: CommandData; privateParams?: Record; }) { - this.client = client; + this._client = client; this.sandboxId = sandboxId; this.cmd = cmd; this.exitCode = cmd.exitCode ?? null; @@ -105,7 +153,13 @@ export class Command { * to access output as a string. */ logs(opts?: { signal?: AbortSignal }) { - return this.client.getLogs({ + if (!this._client) { + throw new Error( + "Cannot call logs() on a deserialized Command without an API client. " + + "Use output(), stdout(), or stderr() instead, which are step-compatible.", + ); + } + return this._client.getLogs({ sandboxId: this.sandboxId, cmdId: this.cmd.id, signal: opts?.signal, @@ -133,9 +187,11 @@ export class Command { * @returns A {@link CommandFinished} instance with populated exit code. */ async wait(params?: { signal?: AbortSignal }) { + "use step"; + const client = await this.ensureClient(); params?.signal?.throwIfAborted(); - const command = await this.client.getCommand({ + const command = await client.getCommand({ sandboxId: this.sandboxId, cmdId: this.cmd.id, wait: true, @@ -144,7 +200,7 @@ export class Command { }); return new CommandFinished({ - client: this.client, + client, sandboxId: this.sandboxId, cmd: command.json.command, exitCode: command.json.command.exitCode, @@ -164,10 +220,16 @@ export class Command { if (!this.outputCache) { this.outputCache = (async () => { try { + const client = await this.ensureClient(); let stdout = ""; let stderr = ""; let both = ""; - for await (const log of this.logs({ signal: opts?.signal })) { + for await (const log of client.getLogs({ + sandboxId: this.sandboxId, + cmdId: this.cmd.id, + signal: opts?.signal, + ...this.privateParams, + })) { both += log.data; if (log.stream === "stdout") { stdout += log.data; @@ -202,6 +264,7 @@ export class Command { stream: "stdout" | "stderr" | "both" = "both", opts?: { signal?: AbortSignal }, ) { + "use step"; const cached = await this.getCachedOutput(opts); return cached[stream]; } @@ -217,7 +280,9 @@ export class Command { * @returns The standard output of the command. */ async stdout(opts?: { signal?: AbortSignal }) { - return this.output("stdout", opts); + "use step"; + const cached = await this.getCachedOutput(opts); + return cached.stdout; } /** @@ -231,7 +296,9 @@ export class Command { * @returns The standard error output of the command. */ async stderr(opts?: { signal?: AbortSignal }) { - return this.output("stderr", opts); + "use step"; + const cached = await this.getCachedOutput(opts); + return cached.stderr; } /** @@ -243,7 +310,9 @@ export class Command { * @returns Promise. */ async kill(signal?: Signal, opts?: { abortSignal?: AbortSignal }) { - await this.client.killCommand({ + "use step"; + const client = await this.ensureClient(); + await client.killCommand({ sandboxId: this.sandboxId, commandId: this.cmd.id, signal: resolveSignal(signal ?? "SIGTERM"), @@ -269,6 +338,37 @@ export class CommandFinished extends Command { */ public exitCode: number; + /** + * Serialize a CommandFinished instance for Workflow DevKit. + */ + static [WORKFLOW_SERIALIZE](instance: CommandFinished) { + return { + sandboxId: (instance as any).sandboxId, + cmd: (instance as any).cmd, + exitCode: instance.exitCode, + privateParams: instance.privateParams, + }; + } + + /** + * Deserialize a CommandFinished instance for Workflow DevKit. + */ + static [WORKFLOW_DESERIALIZE](data: { + sandboxId: string; + cmd: CommandData; + exitCode: number; + privateParams: Record; + }): CommandFinished { + const instance = Object.create(CommandFinished.prototype); + instance._client = null; + instance.sandboxId = data.sandboxId; + instance.cmd = data.cmd; + instance.exitCode = data.exitCode; + instance.privateParams = data.privateParams; + instance.outputCache = null; + return instance; + } + /** * @param params - Object containing client, sandbox ID, command ID, and exit code. * @param params.client - API client used to interact with the backend. @@ -278,7 +378,7 @@ export class CommandFinished extends Command { * @param params.privateParams - Private parameters to pass to API calls. */ constructor(params: { - client: APIClient; + client: APIClient | null; sandboxId: string; cmd: CommandData; exitCode: number; diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 75e31a8..8d90454 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -18,6 +18,7 @@ import { type NetworkTransformer, } from "./network-policy"; import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; @@ -148,9 +149,26 @@ interface RunCommandParams { * @hideconstructor */ export class Sandbox { - private readonly client: APIClient; + private _client: APIClient | null; private readonly privateParams: Record; + /** + * Lazily resolve credentials and construct an API client. + * This is used in step contexts where the Sandbox was deserialized + * without a client (e.g. when crossing workflow/step boundaries). + * @internal + */ + private async ensureClient(): Promise { + "use step"; + if (this._client) return this._client; + const credentials = await getCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + }); + return this._client; + } + /** * Routes from ports to subdomains. /* @hidden @@ -208,6 +226,42 @@ export class Sandbox { */ private sandbox: ConvertedSandbox; + /** + * Serialize a Sandbox instance for Workflow DevKit. + * The serialized data includes the sandbox metadata, routes, and private params. + * The API client is NOT serialized — it is lazily reconstructed from + * environment credentials when needed in step execution contexts. + */ + static [WORKFLOW_SERIALIZE](instance: Sandbox) { + return { + sandbox: instance.sandbox, + routes: instance.routes, + privateParams: instance.privateParams, + }; + } + + /** + * Deserialize a Sandbox instance for Workflow DevKit. + * Reconstructs the Sandbox from serialized metadata without an API client. + * The client will be lazily created via `ensureClient()` when step methods + * are invoked. + */ + static [WORKFLOW_DESERIALIZE](data: { + sandbox: ConvertedSandbox; + routes: SandboxRouteData[]; + privateParams: Record; + }): Sandbox { + // Use Object.create() to bypass the constructor, since the constructor + // expects raw SandboxMetaData and runs convertSandbox(). The serialized + // data already has the converted format. + const instance = Object.create(Sandbox.prototype); + instance._client = null; + instance.sandbox = data.sandbox; + instance.routes = data.routes; + instance.privateParams = data.privateParams; + return instance; + } + /** * Allow to get a list of sandboxes for a team narrowed to the given params. * It returns both the sandboxes and the pagination metadata to allow getting @@ -218,6 +272,7 @@ export class Sandbox { Partial & WithFetchOptions, ) { + "use step"; const credentials = await getCredentials(params); const client = new APIClient({ teamId: credentials.teamId, @@ -248,6 +303,7 @@ export class Sandbox { > & WithFetchOptions, ): Promise { + "use step"; const credentials = await getCredentials(params); const client = new APIClient({ teamId: credentials.teamId, @@ -286,6 +342,7 @@ export class Sandbox { params: WithPrivate & WithFetchOptions, ): Promise { + "use step"; const credentials = await getCredentials(params); const client = new APIClient({ teamId: credentials.teamId, @@ -314,12 +371,12 @@ export class Sandbox { sandbox, privateParams, }: { - client: APIClient; + client: APIClient | null; routes: SandboxRouteData[]; sandbox: SandboxMetaData; privateParams?: Record; }) { - this.client = client; + this._client = client; this.routes = routes; this.sandbox = convertSandbox(sandbox); this.privateParams = privateParams ?? {}; @@ -337,7 +394,9 @@ export class Sandbox { cmdId: string, opts?: { signal?: AbortSignal }, ): Promise { - const command = await this.client.getCommand({ + "use step"; + const client = await this.ensureClient(); + const command = await client.getCommand({ sandboxId: this.sandbox.id, cmdId, signal: opts?.signal, @@ -345,7 +404,7 @@ export class Sandbox { }); return new Command({ - client: this.client, + client, sandboxId: this.sandbox.id, cmd: command.json.command, privateParams: this.privateParams, @@ -403,6 +462,8 @@ export class Sandbox { * @internal */ async _runCommand(params: RunCommandParams) { + "use step"; + const client = await this.ensureClient(); const wait = params.detached ? false : true; const getLogs = (command: Command) => { if (params.stdout || params.stderr) { @@ -423,10 +484,10 @@ export class Sandbox { } })(); } - } + }; if (wait) { - const commandStream = await this.client.runCommand({ + const commandStream = await client.runCommand({ sandboxId: this.sandbox.id, command: params.cmd, args: params.args ?? [], @@ -439,7 +500,7 @@ export class Sandbox { }); const command = new Command({ - client: this.client, + client, sandboxId: this.sandbox.id, cmd: commandStream.command, privateParams: this.privateParams, @@ -449,7 +510,7 @@ export class Sandbox { const finished = await commandStream.finished; return new CommandFinished({ - client: this.client, + client, sandboxId: this.sandbox.id, cmd: finished, exitCode: finished.exitCode ?? 0, @@ -457,7 +518,7 @@ export class Sandbox { }); } - const commandResponse = await this.client.runCommand({ + const commandResponse = await client.runCommand({ sandboxId: this.sandbox.id, command: params.cmd, args: params.args ?? [], @@ -469,7 +530,7 @@ export class Sandbox { }); const command = new Command({ - client: this.client, + client, sandboxId: this.sandbox.id, cmd: commandResponse.json.command, privateParams: this.privateParams, @@ -488,7 +549,9 @@ export class Sandbox { * @param opts.signal - An AbortSignal to cancel the operation. */ async mkDir(path: string, opts?: { signal?: AbortSignal }): Promise { - await this.client.mkDir({ + "use step"; + const client = await this.ensureClient(); + await client.mkDir({ sandboxId: this.sandbox.id, path: path, signal: opts?.signal, @@ -508,7 +571,9 @@ export class Sandbox { file: { path: string; cwd?: string }, opts?: { signal?: AbortSignal }, ): Promise { - return this.client.readFile({ + "use step"; + const client = await this.ensureClient(); + return client.readFile({ sandboxId: this.sandbox.id, path: file.path, cwd: file.cwd, @@ -529,7 +594,9 @@ export class Sandbox { file: { path: string; cwd?: string }, opts?: { signal?: AbortSignal }, ): Promise { - const stream = await this.client.readFile({ + "use step"; + const client = await this.ensureClient(); + const stream = await client.readFile({ sandboxId: this.sandbox.id, path: file.path, cwd: file.cwd, @@ -559,6 +626,8 @@ export class Sandbox { dst: { path: string; cwd?: string }, opts?: { mkdirRecursive?: boolean; signal?: AbortSignal }, ): Promise { + "use step"; + const client = await this.ensureClient(); if (!src?.path) { throw new Error("downloadFile: source path is required"); } @@ -567,7 +636,7 @@ export class Sandbox { throw new Error("downloadFile: destination path is required"); } - const stream = await this.client.readFile({ + const stream = await client.readFile({ sandboxId: this.sandbox.id, path: src.path, cwd: src.cwd, @@ -589,7 +658,7 @@ export class Sandbox { }); return dstPath; } finally { - stream.destroy() + stream.destroy(); } } @@ -607,7 +676,9 @@ export class Sandbox { files: { path: string; content: Buffer }[], opts?: { signal?: AbortSignal }, ) { - return this.client.writeFiles({ + "use step"; + const client = await this.ensureClient(); + return client.writeFiles({ sandboxId: this.sandbox.id, cwd: this.sandbox.cwd, extractDir: "/", @@ -641,7 +712,9 @@ export class Sandbox { * @returns A promise that resolves when the sandbox is stopped */ async stop(opts?: { signal?: AbortSignal }) { - await this.client.stopSandbox({ + "use step"; + const client = await this.ensureClient(); + await client.stopSandbox({ sandboxId: this.sandbox.id, signal: opts?.signal, ...this.privateParams, @@ -683,14 +756,18 @@ export class Sandbox { networkPolicy: NetworkPolicy, opts?: { signal?: AbortSignal }, ): Promise { - const response = await this.client.updateNetworkPolicy({ + "use step"; + const client = await this.ensureClient(); + const response = await client.updateNetworkPolicy({ sandboxId: this.sandbox.id, networkPolicy: networkPolicy, signal: opts?.signal, ...this.privateParams, }); - // Update the internal sandbox metadata with the new timeout value + // Update the internal sandbox metadata with the new timeout value. + // Note: In workflow contexts, this mutation only affects the step copy + // due to pass-by-value semantics. this.sandbox = convertSandbox(response.json.sandbox); return this.sandbox.networkPolicy!; } @@ -715,14 +792,18 @@ export class Sandbox { duration: number, opts?: { signal?: AbortSignal }, ): Promise { - const response = await this.client.extendTimeout({ + "use step"; + const client = await this.ensureClient(); + const response = await client.extendTimeout({ sandboxId: this.sandbox.id, duration, signal: opts?.signal, ...this.privateParams, }); - // Update the internal sandbox metadata with the new timeout value + // Update the internal sandbox metadata with the new timeout value. + // Note: In workflow contexts, this mutation only affects the step copy + // due to pass-by-value semantics. this.sandbox = convertSandbox(response.json.sandbox); } @@ -741,17 +822,21 @@ export class Sandbox { expiration?: number; signal?: AbortSignal; }): Promise { - const response = await this.client.createSnapshot({ + "use step"; + const client = await this.ensureClient(); + const response = await client.createSnapshot({ sandboxId: this.sandbox.id, expiration: opts?.expiration, signal: opts?.signal, ...this.privateParams, }); + // Note: In workflow contexts, this mutation only affects the step copy + // due to pass-by-value semantics. this.sandbox = convertSandbox(response.json.sandbox); return new Snapshot({ - client: this.client, + client, snapshot: response.json.snapshot, }); } @@ -767,6 +852,23 @@ export class Sandbox { * // Sandbox is automatically stopped here */ class DisposableSandbox extends Sandbox implements AsyncDisposable { + static [WORKFLOW_SERIALIZE](instance: DisposableSandbox) { + return Sandbox[WORKFLOW_SERIALIZE](instance); + } + + static [WORKFLOW_DESERIALIZE](data: { + sandbox: ConvertedSandbox; + routes: SandboxRouteData[]; + privateParams: Record; + }): DisposableSandbox { + const instance = Object.create(DisposableSandbox.prototype); + instance._client = null; + instance.sandbox = data.sandbox; + instance.routes = data.routes; + instance.privateParams = data.privateParams; + return instance; + } + async [Symbol.asyncDispose]() { await this.stop(); } diff --git a/packages/vercel-sandbox/src/snapshot.ts b/packages/vercel-sandbox/src/snapshot.ts index 3ae1f66..da27d61 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -2,6 +2,7 @@ import type { SnapshotMetadata } from "./api-client"; import { APIClient } from "./api-client"; import { WithFetchOptions } from "./api-client/api-client"; import { Credentials, getCredentials } from "./utils/get-credentials"; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; /** @inline */ interface GetSnapshotParams { @@ -22,7 +23,22 @@ interface GetSnapshotParams { * @hideconstructor */ export class Snapshot { - private readonly client: APIClient; + private _client: APIClient | null; + + /** + * Lazily resolve credentials and construct an API client. + * @internal + */ + private async ensureClient(): Promise { + "use step"; + if (this._client) return this._client; + const credentials = await getCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + }); + return this._client; + } /** * Unique ID of this snapshot. @@ -75,6 +91,27 @@ export class Snapshot { */ private snapshot: SnapshotMetadata; + /** + * Serialize a Snapshot instance for Workflow DevKit. + */ + static [WORKFLOW_SERIALIZE](instance: Snapshot) { + return { + snapshot: instance.snapshot, + }; + } + + /** + * Deserialize a Snapshot instance for Workflow DevKit. + */ + static [WORKFLOW_DESERIALIZE](data: { + snapshot: SnapshotMetadata; + }): Snapshot { + const instance = Object.create(Snapshot.prototype); + instance._client = null; + instance.snapshot = data.snapshot; + return instance; + } + /** * Create a new Snapshot instance. * @@ -85,10 +122,10 @@ export class Snapshot { client, snapshot, }: { - client: APIClient; + client: APIClient | null; snapshot: SnapshotMetadata; }) { - this.client = client; + this._client = client; this.snapshot = snapshot; } @@ -102,6 +139,7 @@ export class Snapshot { Partial & WithFetchOptions, ) { + "use step"; const credentials = await getCredentials(params); const client = new APIClient({ teamId: credentials.teamId, @@ -123,6 +161,7 @@ export class Snapshot { static async get( params: GetSnapshotParams | (GetSnapshotParams & Credentials), ): Promise { + "use step"; const credentials = await getCredentials(params); const client = new APIClient({ teamId: credentials.teamId, @@ -148,11 +187,15 @@ export class Snapshot { * @returns A promise that resolves once the snapshot has been deleted. */ async delete(opts?: { signal?: AbortSignal }): Promise { - const response = await this.client.deleteSnapshot({ + "use step"; + const client = await this.ensureClient(); + const response = await client.deleteSnapshot({ snapshotId: this.snapshot.id, signal: opts?.signal, }); + // Note: In workflow contexts, this mutation only affects the step copy + // due to pass-by-value semantics. this.snapshot = response.json.snapshot; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a58778..1c0e1d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: '@vercel/oidc': specifier: 3.2.0 version: 3.2.0 + '@workflow/serde': + specifier: ^4.1.0-beta.2 + version: 4.1.0-beta.2 async-retry: specifier: 1.3.3 version: 1.3.3 @@ -1541,6 +1544,9 @@ packages: '@vitest/utils@3.2.1': resolution: {integrity: sha512-KkHlGhePEKZSub5ViknBcN5KEF+u7dSUr9NW8QsVICusUojrgrOnnY3DEWWO877ax2Pyopuk2qHmt+gkNKnBVw==} + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -4853,6 +4859,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@workflow/serde@4.1.0-beta.2': {} + acorn-walk@8.3.4: dependencies: acorn: 8.15.0