From 4e45f2de558746da3c987b7737fc6b5b923c6d92 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 24 Jan 2026 14:09:30 -0800 Subject: [PATCH 1/9] Support "use workflow" sealization for Sandbox and Command Does *not* add `"use step"` anywhere, so users will have to do this themselves. It could be done, but it is hard to reconzile with the use of AbortSignals and also won't be very useful in practice. Introduces `Sandbox.setCredentials` to allow creating APIClient from serialized values. --- packages/vercel-sandbox/package.json | 1 + .../src/command.serialize.test.ts | 422 ++++++++++++++++++ packages/vercel-sandbox/src/command.ts | 170 ++++++- packages/vercel-sandbox/src/index.ts | 2 + .../src/sandbox.serialize.test.ts | 358 +++++++++++++++ packages/vercel-sandbox/src/sandbox.ts | 169 +++++-- pnpm-lock.yaml | 8 + 7 files changed, 1084 insertions(+), 46 deletions(-) create mode 100644 packages/vercel-sandbox/src/command.serialize.test.ts create mode 100644 packages/vercel-sandbox/src/sandbox.serialize.test.ts diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 3e3af4b..c6fb753 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.0.1-beta.1", "async-retry": "1.3.3", "jsonlines": "0.1.1", "ms": "2.1.3", diff --git a/packages/vercel-sandbox/src/command.serialize.test.ts b/packages/vercel-sandbox/src/command.serialize.test.ts new file mode 100644 index 0000000..c8e00ae --- /dev/null +++ b/packages/vercel-sandbox/src/command.serialize.test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, vi } from "vitest"; +import { + WORKFLOW_SERIALIZE, + WORKFLOW_DESERIALIZE, +} from "@workflow/serde"; +import { + Command, + CommandFinished, + SerializedCommandFinished, + CommandOutput, +} from "./command"; +import type { CommandData } from "./api-client"; +import { APIClient } from "./api-client"; + +describe("CommandFinished serialization", () => { + const mockCommandData: CommandData = { + id: "cmd_test123", + name: "echo", + args: ["hello", "world"], + cwd: "/vercel/sandbox", + sandboxId: "sbx_test456", + exitCode: 0, + startedAt: 1700000000000, + }; + + const mockSandboxId = "sbx_test456"; + + const mockOutput: CommandOutput = { + stdout: "Hello, world!\n", + stderr: "", + }; + + const createMockCommandFinished = ( + cmd: CommandData = mockCommandData, + sandboxId: string = mockSandboxId, + exitCode: number = 0, + output?: CommandOutput, + ): CommandFinished => { + const client = new APIClient({ + teamId: "team_test", + token: "test_token", + }); + + return new CommandFinished({ + client, + sandboxId, + cmd, + exitCode, + output, + }); + }; + + describe("WORKFLOW_SERIALIZE", () => { + it("serializes a CommandFinished instance with output", () => { + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + mockOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized).toEqual({ + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }); + }); + + it("serializes without output if not fetched", () => { + const commandFinished = createMockCommandFinished(); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.sandboxId).toBe(mockSandboxId); + expect(serialized.cmd).toEqual(mockCommandData); + expect(serialized.exitCode).toBe(0); + expect(serialized.output).toBeUndefined(); + }); + + it("preserves the exit code", () => { + const commandFinished = createMockCommandFinished( + { ...mockCommandData, exitCode: 42 }, + mockSandboxId, + 42, + mockOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.exitCode).toBe(42); + }); + + it("preserves stdout in output", () => { + const customOutput: CommandOutput = { + stdout: "Custom stdout\n", + stderr: "Custom stderr\n", + }; + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + customOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.output.stdout).toBe("Custom stdout\n"); + expect(serialized.output.stderr).toBe("Custom stderr\n"); + }); + + it("returns a plain object that can be JSON serialized", () => { + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + mockOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + const jsonString = JSON.stringify(serialized); + const parsed = JSON.parse(jsonString); + + expect(parsed.exitCode).toBe(0); + expect(parsed.sandboxId).toBe(mockSandboxId); + expect(parsed.output).toEqual(mockOutput); + }); + + it("does not include the API client", () => { + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + mockOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized).not.toHaveProperty("client"); + expect(JSON.stringify(serialized)).not.toContain("token"); + }); + }); + + describe("WORKFLOW_DESERIALIZE", () => { + it("creates a CommandFinished instance from serialized data", () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + + expect(commandFinished).toBeInstanceOf(CommandFinished); + expect(commandFinished.exitCode).toBe(0); + }); + + it("returns synchronously (not a promise)", () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + const result = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + + // Should not be a promise + expect(result).toBeInstanceOf(CommandFinished); + expect(result).not.toBeInstanceOf(Promise); + }); + + it("preserves exit code after deserialization", () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: { ...mockCommandData, exitCode: 127 }, + exitCode: 127, + output: mockOutput, + }; + + const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + + expect(commandFinished.exitCode).toBe(127); + }); + + it("preserves command properties after deserialization", () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + + expect(commandFinished.cmdId).toBe(mockCommandData.id); + expect(commandFinished.cwd).toBe(mockCommandData.cwd); + expect(commandFinished.startedAt).toBe(mockCommandData.startedAt); + }); + + it("restores output for stdout() and stderr() methods", async () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + + expect(await commandFinished.stdout()).toBe(mockOutput.stdout); + expect(await commandFinished.stderr()).toBe(mockOutput.stderr); + }); + + it("deserialized instance has no client until accessed", () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + + // Client is lazily created - internal _client should be null initially + // (accessing .client would create one using OIDC by default) + expect((commandFinished as unknown as { _client: unknown })._client).toBeNull(); + }); + }); + + describe("roundtrip serialization", () => { + it("serializes and deserializes a CommandFinished", async () => { + const originalCommand = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 42, + mockOutput, + ); + + // Serialize + const serialized = CommandFinished[WORKFLOW_SERIALIZE](originalCommand); + + // Deserialize + const deserialized = CommandFinished[WORKFLOW_DESERIALIZE](serialized); + + expect(deserialized.cmdId).toBe(originalCommand.cmdId); + expect(deserialized.exitCode).toBe(42); + expect(await deserialized.stdout()).toBe(mockOutput.stdout); + expect(await deserialized.stderr()).toBe(mockOutput.stderr); + }); + + it("serialized data can be stored and retrieved via JSON", async () => { + const originalCommand = createMockCommandFinished( + { ...mockCommandData, exitCode: 42 }, + mockSandboxId, + 42, + mockOutput, + ); + + // Serialize to JSON (simulating storage) + const serialized = CommandFinished[WORKFLOW_SERIALIZE](originalCommand); + const storedJson = JSON.stringify(serialized); + + // Retrieve from storage and deserialize + const retrievedData: SerializedCommandFinished = JSON.parse(storedJson); + const deserialized = CommandFinished[WORKFLOW_DESERIALIZE](retrievedData); + + expect(deserialized.cmdId).toBe(originalCommand.cmdId); + expect(deserialized.exitCode).toBe(42); + expect(await deserialized.stdout()).toBe(mockOutput.stdout); + }); + }); + + describe("SerializedCommandFinished type", () => { + it("contains all required fields", () => { + const serializedData: SerializedCommandFinished = { + sandboxId: "sbx_test", + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + expect(serializedData).toHaveProperty("sandboxId"); + expect(serializedData).toHaveProperty("cmd"); + expect(serializedData).toHaveProperty("exitCode"); + expect(serializedData).toHaveProperty("output"); + }); + + it("output contains stdout and stderr", () => { + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + mockOutput, + ); + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.output).toHaveProperty("stdout"); + expect(serialized.output).toHaveProperty("stderr"); + }); + }); + + describe("edge cases", () => { + it("handles empty output", async () => { + const emptyOutput: CommandOutput = { stdout: "", stderr: "" }; + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + emptyOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + expect(serialized.output).toEqual(emptyOutput); + + const deserialized = CommandFinished[WORKFLOW_DESERIALIZE](serialized); + expect(await deserialized.stdout()).toBe(""); + expect(await deserialized.stderr()).toBe(""); + }); + + it("handles large output", () => { + const largeOutput: CommandOutput = { + stdout: "x".repeat(10000), + stderr: "y".repeat(10000), + }; + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + largeOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.output.stdout.length).toBe(10000); + expect(serialized.output.stderr.length).toBe(10000); + }); + + it("handles output with special characters", async () => { + const specialOutput: CommandOutput = { + stdout: "Hello\nWorld\t\"quoted\"\n", + stderr: "Error: 日本語\n", + }; + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + specialOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + expect(serialized.output).toEqual(specialOutput); + + const deserialized = CommandFinished[WORKFLOW_DESERIALIZE](serialized); + expect(await deserialized.stdout()).toBe(specialOutput.stdout); + expect(await deserialized.stderr()).toBe(specialOutput.stderr); + }); + + it("handles exit code 0", () => { + const commandFinished = createMockCommandFinished( + { ...mockCommandData, exitCode: 0 }, + mockSandboxId, + 0, + mockOutput, + ); + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.exitCode).toBe(0); + }); + + it("handles high exit code (255)", () => { + const commandFinished = createMockCommandFinished( + { ...mockCommandData, exitCode: 255 }, + mockSandboxId, + 255, + mockOutput, + ); + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.exitCode).toBe(255); + }); + + it("handles command with special characters in args", () => { + const cmdWithSpecialArgs: CommandData = { + ...mockCommandData, + args: ["--flag=value", "-x", "hello world", "path/to/file"], + }; + const commandFinished = createMockCommandFinished( + cmdWithSpecialArgs, + mockSandboxId, + 0, + mockOutput, + ); + + const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); + + expect(serialized.cmd.args).toEqual([ + "--flag=value", + "-x", + "hello world", + "path/to/file", + ]); + }); + + it("wait() returns this for deserialized instances", async () => { + const serializedData: SerializedCommandFinished = { + sandboxId: mockSandboxId, + cmd: mockCommandData, + exitCode: 0, + output: mockOutput, + }; + + const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const waited = await commandFinished.wait(); + + expect(waited).toBe(commandFinished); + }); + }); +}); diff --git a/packages/vercel-sandbox/src/command.ts b/packages/vercel-sandbox/src/command.ts index 0729d13..86f2849 100644 --- a/packages/vercel-sandbox/src/command.ts +++ b/packages/vercel-sandbox/src/command.ts @@ -1,5 +1,32 @@ import { APIClient, type CommandData } from "./api-client"; import { Signal, resolveSignal } from "./utils/resolveSignal"; +import { getGlobalCredentials } from "./sandbox"; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; + +/** + * Cached output from a command execution. + */ +export interface CommandOutput { + stdout: string; + stderr: string; +} + +/** + * Serialized representation of a Command for @workflow/serde. + */ +export interface SerializedCommand { + sandboxId: string; + cmd: CommandData; + /** Cached output, included if output was fetched before serialization */ + output?: CommandOutput; +} + +/** + * Serialized representation of a CommandFinished for @workflow/serde. + */ +export interface SerializedCommandFinished extends SerializedCommand { + exitCode: number; +} /** * A command executed in a Sandbox. @@ -16,29 +43,51 @@ import { Signal, resolveSignal } from "./utils/resolveSignal"; */ export class Command { /** + * Cached API client instance. * @internal - * @private */ - protected client: APIClient; + protected _client: APIClient | null = null; + + /** + * Lazily get or create the API client. + * If no client was provided during construction, creates one using global credentials. + */ + get client(): APIClient { + if (!this._client) { + const credentials = getGlobalCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId!, + token: credentials.token!, + }); + } + return this._client; + } /** * ID of the sandbox this command is running in. */ - private sandboxId: string; + protected sandboxId: string; /** * Data for the command execution. */ - private cmd: CommandData; + protected cmd: CommandData; public exitCode: number | null; - private outputCache: Promise<{ + protected outputCache: Promise<{ stdout: string; stderr: string; both: string; }> | null = null; + /** + * Synchronously accessible resolved output, populated after output is fetched. + * Used for serialization. + * @internal + */ + protected _resolvedOutput: CommandOutput | null = null; + /** * ID of the command execution. */ @@ -55,24 +104,69 @@ export class Command { } /** - * @param params - Object containing the client, sandbox ID, and command ID. - * @param params.client - API client used to interact with the backend. + * @param params - Object containing the client, sandbox ID, and command data. + * @param params.client - Optional API client. If not provided, will be lazily created using global credentials. * @param params.sandboxId - The ID of the sandbox where the command is running. - * @param params.cmdId - The ID of the command execution. + * @param params.cmd - The command data. + * @param params.output - Optional cached output to restore (used during deserialization). */ constructor({ client, sandboxId, cmd, + output, }: { - client: APIClient; + client?: APIClient; sandboxId: string; cmd: CommandData; + output?: CommandOutput; }) { - this.client = client; + this._client = client ?? null; this.sandboxId = sandboxId; this.cmd = cmd; this.exitCode = cmd.exitCode ?? null; + if (output) { + this._resolvedOutput = output; + this.outputCache = Promise.resolve({ + stdout: output.stdout, + stderr: output.stderr, + both: output.stdout + output.stderr, + }); + } + } + + /** + * Serialize a Command instance to plain data for @workflow/serde. + * + * @param instance - The Command instance to serialize + * @returns A plain object containing the sandbox ID, command data, and output if fetched + */ + static [WORKFLOW_SERIALIZE](instance: Command): SerializedCommand { + const serialized: SerializedCommand = { + sandboxId: instance.sandboxId, + cmd: instance.cmd, + }; + if (instance._resolvedOutput) { + serialized.output = instance._resolvedOutput; + } + return serialized; + } + + /** + * Deserialize plain data back into a Command instance for @workflow/serde. + * + * The deserialized instance will lazily create an API client using global credentials + * when needed. Call {@link Sandbox.setCredentials} before using the deserialized instance. + * + * @param data - The serialized command data + * @returns The reconstructed Command instance + */ + static [WORKFLOW_DESERIALIZE](data: SerializedCommand): Command { + return new Command({ + sandboxId: data.sandboxId, + cmd: data.cmd, + output: data.output, + }); } /** @@ -144,7 +238,7 @@ export class Command { * Get cached output, fetching logs only once and reusing for concurrent calls. * This prevents race conditions when stdout() and stderr() are called in parallel. */ - private async getCachedOutput(opts?: { signal?: AbortSignal }): Promise<{ + protected async getCachedOutput(opts?: { signal?: AbortSignal }): Promise<{ stdout: string; stderr: string; both: string; @@ -163,6 +257,8 @@ export class Command { stderr += log.data; } } + // Store resolved output for serialization + this._resolvedOutput = { stdout, stderr }; return { stdout, stderr, both }; } catch (err) { // Clear the promise so future calls can retry @@ -188,7 +284,7 @@ export class Command { */ async output( stream: "stdout" | "stderr" | "both" = "both", - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ) { const cached = await this.getCachedOutput(opts); return cached[stream]; @@ -257,22 +353,64 @@ export class CommandFinished extends Command { public exitCode: number; /** - * @param params - Object containing client, sandbox ID, command ID, and exit code. - * @param params.client - API client used to interact with the backend. + * @param params - Object containing client, sandbox ID, command data, and exit code. + * @param params.client - Optional API client. If not provided, will be lazily created using global credentials. * @param params.sandboxId - The ID of the sandbox where the command ran. - * @param params.cmdId - The ID of the command execution. + * @param params.cmd - The command data. * @param params.exitCode - The exit code of the completed command. + * @param params.output - Optional cached output to restore (used during deserialization). */ constructor(params: { - client: APIClient; + client?: APIClient; sandboxId: string; cmd: CommandData; exitCode: number; + output?: CommandOutput; }) { super({ ...params }); this.exitCode = params.exitCode; } + /** + * Serialize a CommandFinished instance to plain data for @workflow/serde. + * + * @param instance - The CommandFinished instance to serialize + * @returns A plain object containing the sandbox ID, command data, exit code, and output if fetched + */ + static [WORKFLOW_SERIALIZE]( + instance: CommandFinished + ): SerializedCommandFinished { + const serialized: SerializedCommandFinished = { + sandboxId: instance.sandboxId, + cmd: instance.cmd, + exitCode: instance.exitCode, + }; + if (instance._resolvedOutput) { + serialized.output = instance._resolvedOutput; + } + return serialized; + } + + /** + * Deserialize plain data back into a CommandFinished instance for @workflow/serde. + * + * The deserialized instance will lazily create an API client using global credentials + * when needed. Call {@link Sandbox.setCredentials} before using the deserialized instance. + * + * @param data - The serialized command finished data + * @returns The reconstructed CommandFinished instance + */ + static [WORKFLOW_DESERIALIZE]( + data: SerializedCommandFinished + ): CommandFinished { + return new CommandFinished({ + sandboxId: data.sandboxId, + cmd: data.cmd, + exitCode: data.exitCode, + output: data.output, + }); + } + /** * The wait method is not needed for CommandFinished instances since * the command has already completed and exitCode is populated. diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 5540c76..1a187a4 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -4,7 +4,9 @@ export { type NetworkPolicyRule, type NetworkTransformer, } from "./sandbox"; +export type { SerializedSandbox } from "./sandbox"; export { Snapshot } from "./snapshot"; export { Command, CommandFinished } from "./command"; +export type { SerializedCommand, SerializedCommandFinished, CommandOutput } from "./command"; export { StreamError } from "./api-client/api-error"; export { APIError } from "./api-client/api-error"; diff --git a/packages/vercel-sandbox/src/sandbox.serialize.test.ts b/packages/vercel-sandbox/src/sandbox.serialize.test.ts new file mode 100644 index 0000000..13e8c95 --- /dev/null +++ b/packages/vercel-sandbox/src/sandbox.serialize.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + WORKFLOW_SERIALIZE, + WORKFLOW_DESERIALIZE, +} from "@workflow/serde"; +import { Sandbox, SerializedSandbox } from "./sandbox"; +import type { SandboxMetaData, SandboxRouteData } from "./api-client"; +import { APIClient } from "./api-client"; + +// Mock the getCredentials function +vi.mock("./utils/get-credentials", () => ({ + getCredentials: vi.fn().mockResolvedValue({ + teamId: "team_test", + token: "test_token", + projectId: "project_test", + }), +})); + +describe("Sandbox serialization", () => { + const mockMetadata: SandboxMetaData = { + id: "sbx_test123", + memory: 2048, + vcpus: 1, + region: "us-east-1", + runtime: "node24", + timeout: 300000, + status: "running", + requestedAt: 1700000000000, + startedAt: 1700000001000, + createdAt: 1700000000000, + cwd: "/vercel/sandbox", + updatedAt: 1700000002000, + }; + + const mockRoutes: SandboxRouteData[] = [ + { url: "https://test-3000.vercel.run", subdomain: "test-3000", port: 3000 }, + { url: "https://test-4000.vercel.run", subdomain: "test-4000", port: 4000 }, + ]; + + const createMockSandbox = ( + metadata: SandboxMetaData = mockMetadata, + routes: SandboxRouteData[] = mockRoutes, + ): Sandbox => { + const client = new APIClient({ + teamId: "team_test", + token: "test_token", + }); + + return new Sandbox({ + client, + sandbox: metadata, + routes, + }); + }; + + describe("WORKFLOW_SERIALIZE", () => { + it("serializes a sandbox instance to plain data", () => { + const sandbox = createMockSandbox(); + + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized).toEqual({ + sandboxId: "sbx_test123", + metadata: mockMetadata, + routes: mockRoutes, + }); + }); + + it("preserves the sandbox ID", () => { + const sandbox = createMockSandbox(); + + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.sandboxId).toBe(sandbox.sandboxId); + }); + + it("preserves all metadata fields", () => { + const metadataWithOptionalFields: SandboxMetaData = { + ...mockMetadata, + requestedStopAt: 1700000003000, + stoppedAt: 1700000004000, + duration: 3000, + sourceSnapshotId: "snap_abc123", + snapshottedAt: 1700000005000, + interactivePort: 8080, + }; + + const sandbox = createMockSandbox(metadataWithOptionalFields); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.metadata).toEqual(metadataWithOptionalFields); + expect(serialized.metadata.sourceSnapshotId).toBe("snap_abc123"); + expect(serialized.metadata.interactivePort).toBe(8080); + }); + + it("preserves route information", () => { + const customRoutes: SandboxRouteData[] = [ + { + url: "https://custom-8080.vercel.run", + subdomain: "custom-8080", + port: 8080, + }, + ]; + + const sandbox = createMockSandbox(mockMetadata, customRoutes); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.routes).toEqual(customRoutes); + expect(serialized.routes).toHaveLength(1); + }); + + it("handles sandbox with empty routes", () => { + const sandbox = createMockSandbox(mockMetadata, []); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.routes).toEqual([]); + }); + + it("handles sandbox with all status types", () => { + const statuses: SandboxMetaData["status"][] = [ + "pending", + "running", + "stopping", + "stopped", + "failed", + "snapshotting", + ]; + + for (const status of statuses) { + const metadataWithStatus = { ...mockMetadata, status }; + const sandbox = createMockSandbox(metadataWithStatus); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.metadata.status).toBe(status); + } + }); + + it("returns a plain object that can be JSON serialized", () => { + const sandbox = createMockSandbox(); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + const jsonString = JSON.stringify(serialized); + const parsed = JSON.parse(jsonString); + + expect(parsed.sandboxId).toBe("sbx_test123"); + expect(parsed.metadata.id).toBe("sbx_test123"); + expect(parsed.routes).toHaveLength(2); + }); + }); + + describe("WORKFLOW_DESERIALIZE", () => { + it("creates a Sandbox instance from serialized data", () => { + const serializedData: SerializedSandbox = { + sandboxId: "sbx_test123", + metadata: mockMetadata, + routes: mockRoutes, + }; + + const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + + expect(result).toBeInstanceOf(Sandbox); + expect(result.sandboxId).toBe("sbx_test123"); + }); + + it("returns synchronously (not a promise)", () => { + const serializedData: SerializedSandbox = { + sandboxId: "sbx_test123", + metadata: mockMetadata, + routes: mockRoutes, + }; + + const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + + expect(result).toBeInstanceOf(Sandbox); + expect(result).not.toBeInstanceOf(Promise); + }); + + it("preserves sandbox properties after deserialization", () => { + const serializedData: SerializedSandbox = { + sandboxId: "sbx_test123", + metadata: mockMetadata, + routes: mockRoutes, + }; + + const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + + expect(result.sandboxId).toBe(mockMetadata.id); + expect(result.status).toBe(mockMetadata.status); + expect(result.timeout).toBe(mockMetadata.timeout); + expect(result.createdAt.getTime()).toBe(mockMetadata.createdAt); + }); + + it("preserves routes after deserialization", () => { + const serializedData: SerializedSandbox = { + sandboxId: "sbx_test123", + metadata: mockMetadata, + routes: mockRoutes, + }; + + const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + + expect(result.routes).toEqual(mockRoutes); + expect(result.routes[0].url).toBe("https://test-3000.vercel.run"); + expect(result.routes[1].url).toBe("https://test-4000.vercel.run"); + }); + + it("deserialized instance has no client until accessed", () => { + const serializedData: SerializedSandbox = { + sandboxId: "sbx_test123", + metadata: mockMetadata, + routes: mockRoutes, + }; + + const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + + // Client is lazily created - internal _client should be null initially + // (accessing .client would create one using OIDC by default) + expect((result as unknown as { _client: unknown })._client).toBeNull(); + }); + }); + + describe("roundtrip serialization", () => { + it("serializes and deserializes a sandbox", () => { + const originalSandbox = createMockSandbox(); + + // Serialize + const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); + + // Deserialize + const deserialized = Sandbox[WORKFLOW_DESERIALIZE](serialized); + + expect(deserialized.sandboxId).toBe(originalSandbox.sandboxId); + }); + + it("preserves sandboxId through roundtrip", () => { + const customMetadata = { ...mockMetadata, id: "sbx_custom_id_456" }; + const originalSandbox = createMockSandbox(customMetadata); + + const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); + expect(serialized.sandboxId).toBe("sbx_custom_id_456"); + + const deserialized = Sandbox[WORKFLOW_DESERIALIZE](serialized); + expect(deserialized.sandboxId).toBe("sbx_custom_id_456"); + }); + + it("serialized data can be stored and retrieved via JSON", () => { + const originalSandbox = createMockSandbox(); + + // Serialize to JSON (simulating storage) + const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); + const storedJson = JSON.stringify(serialized); + + // Retrieve from storage and deserialize + const retrievedData: SerializedSandbox = JSON.parse(storedJson); + const deserialized = Sandbox[WORKFLOW_DESERIALIZE](retrievedData); + + expect(deserialized.sandboxId).toBe(originalSandbox.sandboxId); + }); + }); + + describe("SerializedSandbox type", () => { + it("contains required fields", () => { + const serializedData: SerializedSandbox = { + sandboxId: "sbx_test", + metadata: mockMetadata, + routes: mockRoutes, + }; + + expect(serializedData).toHaveProperty("sandboxId"); + expect(serializedData).toHaveProperty("metadata"); + expect(serializedData).toHaveProperty("routes"); + }); + + it("metadata contains all required SandboxMetaData fields", () => { + const sandbox = createMockSandbox(); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.metadata).toHaveProperty("id"); + expect(serialized.metadata).toHaveProperty("memory"); + expect(serialized.metadata).toHaveProperty("vcpus"); + expect(serialized.metadata).toHaveProperty("region"); + expect(serialized.metadata).toHaveProperty("runtime"); + expect(serialized.metadata).toHaveProperty("timeout"); + expect(serialized.metadata).toHaveProperty("status"); + expect(serialized.metadata).toHaveProperty("requestedAt"); + expect(serialized.metadata).toHaveProperty("createdAt"); + expect(serialized.metadata).toHaveProperty("cwd"); + expect(serialized.metadata).toHaveProperty("updatedAt"); + }); + + it("routes contain all required SandboxRouteData fields", () => { + const sandbox = createMockSandbox(); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + for (const route of serialized.routes) { + expect(route).toHaveProperty("url"); + expect(route).toHaveProperty("subdomain"); + expect(route).toHaveProperty("port"); + } + }); + }); + + describe("edge cases", () => { + it("handles sandbox with minimal metadata", () => { + const minimalMetadata: SandboxMetaData = { + id: "sbx_minimal", + memory: 1024, + vcpus: 1, + region: "us-west-2", + runtime: "python3.13", + timeout: 60000, + status: "pending", + requestedAt: Date.now(), + createdAt: Date.now(), + cwd: "/", + updatedAt: Date.now(), + }; + + const sandbox = createMockSandbox(minimalMetadata, []); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.sandboxId).toBe("sbx_minimal"); + expect(serialized.metadata.sourceSnapshotId).toBeUndefined(); + expect(serialized.routes).toHaveLength(0); + }); + + it("handles sandbox with maximum ports (4)", () => { + const maxRoutes: SandboxRouteData[] = [ + { url: "https://test-3000.vercel.run", subdomain: "test-3000", port: 3000 }, + { url: "https://test-3001.vercel.run", subdomain: "test-3001", port: 3001 }, + { url: "https://test-3002.vercel.run", subdomain: "test-3002", port: 3002 }, + { url: "https://test-3003.vercel.run", subdomain: "test-3003", port: 3003 }, + ]; + + const sandbox = createMockSandbox(mockMetadata, maxRoutes); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.routes).toHaveLength(4); + }); + + it("serialization does not include the API client", () => { + const sandbox = createMockSandbox(); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + // Ensure no client-related data is in the serialized output + expect(serialized).not.toHaveProperty("client"); + expect(JSON.stringify(serialized)).not.toContain("token"); + }); + + it("handles special characters in sandbox ID", () => { + const metadataWithSpecialId = { ...mockMetadata, id: "sbx_test-123_abc" }; + const sandbox = createMockSandbox(metadataWithSpecialId); + const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + + expect(serialized.sandboxId).toBe("sbx_test-123_abc"); + }); + }); +}); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index c93c96a..e7b6a1e 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 }; @@ -115,6 +116,15 @@ interface GetSandboxParams { signal?: AbortSignal; } +/** + * Serialized representation of a Sandbox for @workflow/serde. + */ +export interface SerializedSandbox { + sandboxId: string; + metadata: SandboxMetaData; + routes: SandboxRouteData[]; +} + /** @inline */ interface RunCommandParams { /** @@ -155,6 +165,37 @@ interface RunCommandParams { signal?: AbortSignal; } +// ============================================================================ +// Global credentials storage +// ============================================================================ + +let globalCredentials: Partial = {}; + +/** + * Set global credentials for Sandbox and Command instances. + * These credentials are used when lazily creating API clients for deserialized instances. + * + * If not called, deserialized instances will use OIDC authentication by default. + * + * @param credentials - The credentials to use globally + */ +export function setGlobalCredentials(credentials: Credentials): void { + globalCredentials = credentials; +} + +/** + * Get the global credentials. + * Returns empty object by default (for OIDC authentication). + * @internal + */ +export function getGlobalCredentials(): Partial { + return globalCredentials; +} + +// ============================================================================ +// Sandbox class +// ============================================================================ + /** * A Sandbox is an isolated Linux MicroVM to run commands in. * @@ -162,7 +203,22 @@ interface RunCommandParams { * @hideconstructor */ export class Sandbox { - private readonly client: APIClient; + private _client: APIClient | null = null; + + /** + * Lazily get or create the API client. + * If no client was provided during construction, creates one using global credentials. + */ + get client(): APIClient { + if (!this._client) { + const credentials = getGlobalCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId!, + token: credentials.token!, + }); + } + return this._client; + } /** * Routes from ports to subdomains. @@ -235,6 +291,30 @@ export class Sandbox { */ private sandbox: ConvertedSandbox; + /** + * Set global credentials for Sandbox and Command instances. + * These credentials are used when lazily creating API clients for deserialized instances. + * + * If not called, deserialized instances will use OIDC authentication by default. + * + * @param credentials - The credentials to use globally + * + * @example + * // Set credentials once at application startup + * Sandbox.setCredentials({ + * teamId: 'team_xxx', + * token: 'token_xxx', + * projectId: 'prj_xxx' + * }); + * + * // Now deserialized sandboxes can make API calls + * const sandbox = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + * await sandbox.runCommand('echo', ['hello']); + */ + static setCredentials(credentials: Credentials): void { + setGlobalCredentials(credentials); + } + /** * 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 @@ -243,7 +323,7 @@ export class Sandbox { static async list( params?: Partial[0]> & Partial & - WithFetchOptions, + WithFetchOptions ) { const credentials = await getCredentials(params); const client = new APIClient({ @@ -257,6 +337,36 @@ export class Sandbox { }); } + /** + * Serialize a Sandbox instance to plain data for @workflow/serde. + * + * @param instance - The Sandbox instance to serialize + * @returns A plain object containing the sandbox ID, metadata, and routes + */ + static [WORKFLOW_SERIALIZE](instance: Sandbox): SerializedSandbox { + return { + sandboxId: instance.sandboxId, + metadata: instance.sandbox, + routes: instance.routes, + }; + } + + /** + * Deserialize plain data back into a Sandbox instance for @workflow/serde. + * + * The deserialized instance will lazily create an API client using global credentials + * when needed. Call {@link Sandbox.setCredentials} before using the deserialized instance. + * + * @param data - The serialized sandbox data + * @returns The reconstructed Sandbox instance + */ + static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Sandbox { + return new Sandbox({ + sandbox: data.metadata, + routes: data.routes, + }); + } + /** * Create a new sandbox. * @@ -273,7 +383,7 @@ export class Sandbox { params?: WithPrivate< CreateSandboxParams | (CreateSandboxParams & Credentials) > & - WithFetchOptions, + WithFetchOptions ): Promise { const credentials = await getCredentials(params); const client = new APIClient({ @@ -311,7 +421,7 @@ export class Sandbox { */ static async get( params: WithPrivate & - WithFetchOptions, + WithFetchOptions ): Promise { const credentials = await getCredentials(params); const client = new APIClient({ @@ -334,16 +444,23 @@ export class Sandbox { }); } + /** + * Create a new Sandbox instance. + * + * @param params.client - Optional API client. If not provided, will be lazily created using global credentials. + * @param params.routes - Port-to-subdomain mappings for exposed ports + * @param params.sandbox - Sandbox metadata + */ constructor({ client, routes, sandbox, }: { - client: APIClient; + client?: APIClient; routes: SandboxRouteData[]; sandbox: SandboxMetaData; }) { - this.client = client; + this._client = client ?? null; this.routes = routes; this.sandbox = convertSandbox(sandbox); } @@ -358,7 +475,7 @@ export class Sandbox { */ async getCommand( cmdId: string, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ): Promise { const command = await this.client.getCommand({ sandboxId: this.sandbox.id, @@ -385,7 +502,7 @@ export class Sandbox { async runCommand( command: string, args?: string[], - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ): Promise; /** @@ -395,7 +512,7 @@ export class Sandbox { * @returns A {@link Command} instance for the running command. */ async runCommand( - params: RunCommandParams & { detached: true }, + params: RunCommandParams & { detached: true } ): Promise; /** @@ -409,21 +526,13 @@ export class Sandbox { async runCommand( commandOrParams: string | RunCommandParams, args?: string[], - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ): Promise { - return typeof commandOrParams === "string" - ? this._runCommand({ cmd: commandOrParams, args, signal: opts?.signal }) - : this._runCommand(commandOrParams); - } + const params: RunCommandParams = + typeof commandOrParams === "string" + ? { cmd: commandOrParams, args, signal: opts?.signal } + : commandOrParams; - /** - * Internal helper to start a command in the sandbox. - * - * @param params - Command execution parameters. - * @returns A {@link Command} or {@link CommandFinished}, depending on `detached`. - * @internal - */ - async _runCommand(params: RunCommandParams) { const wait = params.detached ? false : true; const getLogs = (command: Command) => { if (params.stdout || params.stderr) { @@ -444,7 +553,7 @@ export class Sandbox { } })(); } - } + }; if (wait) { const commandStream = await this.client.runCommand({ @@ -464,7 +573,7 @@ export class Sandbox { cmd: commandStream.command, }); - getLogs(command); + getLogs(command); const finished = await commandStream.finished; return new CommandFinished({ @@ -521,7 +630,7 @@ export class Sandbox { */ async readFile( file: { path: string; cwd?: string }, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ): Promise { return this.client.readFile({ sandboxId: this.sandbox.id, @@ -541,7 +650,7 @@ export class Sandbox { */ async readFileToBuffer( file: { path: string; cwd?: string }, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ): Promise { const stream = await this.client.readFile({ sandboxId: this.sandbox.id, @@ -570,7 +679,7 @@ export class Sandbox { async downloadFile( src: { path: string; cwd?: string }, dst: { path: string; cwd?: string }, - opts?: { mkdirRecursive?: boolean; signal?: AbortSignal }, + opts?: { mkdirRecursive?: boolean; signal?: AbortSignal } ): Promise { if (!src?.path) { throw new Error("downloadFile: source path is required"); @@ -601,7 +710,7 @@ export class Sandbox { }); return dstPath; } finally { - stream.destroy() + stream.destroy(); } } @@ -617,7 +726,7 @@ export class Sandbox { */ async writeFiles( files: { path: string; content: Buffer }[], - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ) { return this.client.writeFiles({ sandboxId: this.sandbox.id, @@ -726,7 +835,7 @@ export class Sandbox { */ async extendTimeout( duration: number, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal } ): Promise { const response = await this.client.extendTimeout({ sandboxId: this.sandbox.id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b28a858..3f2f3e7 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.0.1-beta.1 + version: 4.0.1-beta.1 async-retry: specifier: 1.3.3 version: 1.3.3 @@ -1578,6 +1581,9 @@ packages: '@vitest/utils@3.2.1': resolution: {integrity: sha512-KkHlGhePEKZSub5ViknBcN5KEF+u7dSUr9NW8QsVICusUojrgrOnnY3DEWWO877ax2Pyopuk2qHmt+gkNKnBVw==} + '@workflow/serde@4.0.1-beta.1': + resolution: {integrity: sha512-JdCvZk0sD1qk2XzteSHVNGbpRM8YcvRBglSJy3FysYgi19pBkk3me2P9gzFpPrnQS6IhAPnEqnJOAODHg1iZrA==} + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -4894,6 +4900,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@workflow/serde@4.0.1-beta.1': {} + acorn-walk@8.3.4: dependencies: acorn: 8.15.0 From 2ff3c72f539be0dba641aa053a8eb709001c1615 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 1 Mar 2026 06:10:12 -0800 Subject: [PATCH 2/9] Use .get --- packages/vercel-sandbox/src/command.ts | 4 +- .../src/sandbox.serialize.test.ts | 320 +++++------------- packages/vercel-sandbox/src/sandbox.ts | 45 +-- 3 files changed, 120 insertions(+), 249 deletions(-) diff --git a/packages/vercel-sandbox/src/command.ts b/packages/vercel-sandbox/src/command.ts index 86f2849..b5e75b2 100644 --- a/packages/vercel-sandbox/src/command.ts +++ b/packages/vercel-sandbox/src/command.ts @@ -56,8 +56,8 @@ export class Command { if (!this._client) { const credentials = getGlobalCredentials(); this._client = new APIClient({ - teamId: credentials.teamId!, - token: credentials.token!, + teamId: credentials.teamId, + token: credentials.token, }); } return this._client; diff --git a/packages/vercel-sandbox/src/sandbox.serialize.test.ts b/packages/vercel-sandbox/src/sandbox.serialize.test.ts index 13e8c95..6b501dc 100644 --- a/packages/vercel-sandbox/src/sandbox.serialize.test.ts +++ b/packages/vercel-sandbox/src/sandbox.serialize.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE, } from "@workflow/serde"; -import { Sandbox, SerializedSandbox } from "./sandbox"; +import { Sandbox, SerializedSandbox, setGlobalCredentials } from "./sandbox"; import type { SandboxMetaData, SandboxRouteData } from "./api-client"; import { APIClient } from "./api-client"; @@ -54,305 +54,171 @@ describe("Sandbox serialization", () => { }; describe("WORKFLOW_SERIALIZE", () => { - it("serializes a sandbox instance to plain data", () => { + it("serializes to just the sandbox ID", () => { const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - expect(serialized).toEqual({ - sandboxId: "sbx_test123", - metadata: mockMetadata, - routes: mockRoutes, - }); + expect(serialized).toEqual({ sandboxId: "sbx_test123" }); }); it("preserves the sandbox ID", () => { const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); expect(serialized.sandboxId).toBe(sandbox.sandboxId); }); - it("preserves all metadata fields", () => { - const metadataWithOptionalFields: SandboxMetaData = { - ...mockMetadata, - requestedStopAt: 1700000003000, - stoppedAt: 1700000004000, - duration: 3000, - sourceSnapshotId: "snap_abc123", - snapshottedAt: 1700000005000, - interactivePort: 8080, - }; - - const sandbox = createMockSandbox(metadataWithOptionalFields); + it("returns a plain object that can be JSON serialized", () => { + const sandbox = createMockSandbox(); const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - expect(serialized.metadata).toEqual(metadataWithOptionalFields); - expect(serialized.metadata.sourceSnapshotId).toBe("snap_abc123"); - expect(serialized.metadata.interactivePort).toBe(8080); - }); - - it("preserves route information", () => { - const customRoutes: SandboxRouteData[] = [ - { - url: "https://custom-8080.vercel.run", - subdomain: "custom-8080", - port: 8080, - }, - ]; - - const sandbox = createMockSandbox(mockMetadata, customRoutes); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + const jsonString = JSON.stringify(serialized); + const parsed = JSON.parse(jsonString); - expect(serialized.routes).toEqual(customRoutes); - expect(serialized.routes).toHaveLength(1); + expect(parsed.sandboxId).toBe("sbx_test123"); }); - it("handles sandbox with empty routes", () => { - const sandbox = createMockSandbox(mockMetadata, []); + it("does not include the API client or credentials", () => { + const sandbox = createMockSandbox(); const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - expect(serialized.routes).toEqual([]); - }); - - it("handles sandbox with all status types", () => { - const statuses: SandboxMetaData["status"][] = [ - "pending", - "running", - "stopping", - "stopped", - "failed", - "snapshotting", - ]; - - for (const status of statuses) { - const metadataWithStatus = { ...mockMetadata, status }; - const sandbox = createMockSandbox(metadataWithStatus); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - - expect(serialized.metadata.status).toBe(status); - } + expect(serialized).not.toHaveProperty("client"); + expect(serialized).not.toHaveProperty("metadata"); + expect(serialized).not.toHaveProperty("routes"); + expect(JSON.stringify(serialized)).not.toContain("token"); }); - it("returns a plain object that can be JSON serialized", () => { - const sandbox = createMockSandbox(); + it("handles special characters in sandbox ID", () => { + const metadataWithSpecialId = { ...mockMetadata, id: "sbx_test-123_abc" }; + const sandbox = createMockSandbox(metadataWithSpecialId); const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - const jsonString = JSON.stringify(serialized); - const parsed = JSON.parse(jsonString); - - expect(parsed.sandboxId).toBe("sbx_test123"); - expect(parsed.metadata.id).toBe("sbx_test123"); - expect(parsed.routes).toHaveLength(2); + expect(serialized.sandboxId).toBe("sbx_test-123_abc"); }); }); describe("WORKFLOW_DESERIALIZE", () => { - it("creates a Sandbox instance from serialized data", () => { - const serializedData: SerializedSandbox = { - sandboxId: "sbx_test123", - metadata: mockMetadata, - routes: mockRoutes, - }; - - const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); - - expect(result).toBeInstanceOf(Sandbox); - expect(result.sandboxId).toBe("sbx_test123"); + afterEach(() => { + vi.restoreAllMocks(); }); - it("returns synchronously (not a promise)", () => { - const serializedData: SerializedSandbox = { - sandboxId: "sbx_test123", - metadata: mockMetadata, - routes: mockRoutes, - }; + it("calls Sandbox.get with the serialized sandbox ID", async () => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); - const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + const mockSandbox = createMockSandbox(); + const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(mockSandbox); - expect(result).toBeInstanceOf(Sandbox); - expect(result).not.toBeInstanceOf(Promise); + const serializedData: SerializedSandbox = { sandboxId: "sbx_test123" }; + const result = await Sandbox[WORKFLOW_DESERIALIZE](serializedData); + + expect(getSpy).toHaveBeenCalledWith( + expect.objectContaining({ sandboxId: "sbx_test123" }), + ); + expect(result).toBe(mockSandbox); }); - it("preserves sandbox properties after deserialization", () => { - const serializedData: SerializedSandbox = { - sandboxId: "sbx_test123", - metadata: mockMetadata, - routes: mockRoutes, - }; + it("returns a promise", () => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + + vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); - const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + const result = Sandbox[WORKFLOW_DESERIALIZE]({ sandboxId: "sbx_test123" }); - expect(result.sandboxId).toBe(mockMetadata.id); - expect(result.status).toBe(mockMetadata.status); - expect(result.timeout).toBe(mockMetadata.timeout); - expect(result.createdAt.getTime()).toBe(mockMetadata.createdAt); + expect(result).toBeInstanceOf(Promise); }); - it("preserves routes after deserialization", () => { - const serializedData: SerializedSandbox = { - sandboxId: "sbx_test123", - metadata: mockMetadata, - routes: mockRoutes, - }; + it("passes global credentials to Sandbox.get", async () => { + setGlobalCredentials({ token: "my_token", teamId: "my_team" }); - const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); - expect(result.routes).toEqual(mockRoutes); - expect(result.routes[0].url).toBe("https://test-3000.vercel.run"); - expect(result.routes[1].url).toBe("https://test-4000.vercel.run"); + await Sandbox[WORKFLOW_DESERIALIZE]({ sandboxId: "sbx_test123" }); + + expect(getSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxId: "sbx_test123", + token: "my_token", + teamId: "my_team", + }), + ); }); - it("deserialized instance has no client until accessed", () => { - const serializedData: SerializedSandbox = { - sandboxId: "sbx_test123", - metadata: mockMetadata, - routes: mockRoutes, - }; + it("returns a fully functional Sandbox instance", async () => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); - const result = Sandbox[WORKFLOW_DESERIALIZE](serializedData); + const mockSandbox = createMockSandbox(); + vi.spyOn(Sandbox, "get").mockResolvedValue(mockSandbox); - // Client is lazily created - internal _client should be null initially - // (accessing .client would create one using OIDC by default) - expect((result as unknown as { _client: unknown })._client).toBeNull(); + const result = await Sandbox[WORKFLOW_DESERIALIZE]({ sandboxId: "sbx_test123" }); + + expect(result).toBeInstanceOf(Sandbox); + expect(result.sandboxId).toBe("sbx_test123"); + expect(result.status).toBe("running"); + expect(result.routes).toEqual(mockRoutes); }); }); describe("roundtrip serialization", () => { - it("serializes and deserializes a sandbox", () => { - const originalSandbox = createMockSandbox(); - - // Serialize - const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); - - // Deserialize - const deserialized = Sandbox[WORKFLOW_DESERIALIZE](serialized); - - expect(deserialized.sandboxId).toBe(originalSandbox.sandboxId); + afterEach(() => { + vi.restoreAllMocks(); }); - it("preserves sandboxId through roundtrip", () => { - const customMetadata = { ...mockMetadata, id: "sbx_custom_id_456" }; - const originalSandbox = createMockSandbox(customMetadata); + it("preserves sandboxId through roundtrip", async () => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + + const originalSandbox = createMockSandbox(); + vi.spyOn(Sandbox, "get").mockResolvedValue(originalSandbox); const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); - expect(serialized.sandboxId).toBe("sbx_custom_id_456"); + expect(serialized.sandboxId).toBe("sbx_test123"); - const deserialized = Sandbox[WORKFLOW_DESERIALIZE](serialized); - expect(deserialized.sandboxId).toBe("sbx_custom_id_456"); + const deserialized = await Sandbox[WORKFLOW_DESERIALIZE](serialized); + expect(deserialized.sandboxId).toBe("sbx_test123"); }); - it("serialized data can be stored and retrieved via JSON", () => { + it("serialized data can be stored and retrieved via JSON", async () => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + const originalSandbox = createMockSandbox(); + vi.spyOn(Sandbox, "get").mockResolvedValue(originalSandbox); - // Serialize to JSON (simulating storage) const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); const storedJson = JSON.stringify(serialized); - // Retrieve from storage and deserialize const retrievedData: SerializedSandbox = JSON.parse(storedJson); - const deserialized = Sandbox[WORKFLOW_DESERIALIZE](retrievedData); + const deserialized = await Sandbox[WORKFLOW_DESERIALIZE](retrievedData); expect(deserialized.sandboxId).toBe(originalSandbox.sandboxId); }); }); - describe("SerializedSandbox type", () => { - it("contains required fields", () => { - const serializedData: SerializedSandbox = { - sandboxId: "sbx_test", - metadata: mockMetadata, - routes: mockRoutes, - }; - - expect(serializedData).toHaveProperty("sandboxId"); - expect(serializedData).toHaveProperty("metadata"); - expect(serializedData).toHaveProperty("routes"); + describe("global credentials error", () => { + afterEach(() => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); }); - it("metadata contains all required SandboxMetaData fields", () => { - const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + it("throws a helpful error when deserializing without global credentials", async () => { + vi.resetModules(); + const { Sandbox: FreshSandbox } = await import("./sandbox"); - expect(serialized.metadata).toHaveProperty("id"); - expect(serialized.metadata).toHaveProperty("memory"); - expect(serialized.metadata).toHaveProperty("vcpus"); - expect(serialized.metadata).toHaveProperty("region"); - expect(serialized.metadata).toHaveProperty("runtime"); - expect(serialized.metadata).toHaveProperty("timeout"); - expect(serialized.metadata).toHaveProperty("status"); - expect(serialized.metadata).toHaveProperty("requestedAt"); - expect(serialized.metadata).toHaveProperty("createdAt"); - expect(serialized.metadata).toHaveProperty("cwd"); - expect(serialized.metadata).toHaveProperty("updatedAt"); - }); + const serializedData: SerializedSandbox = { sandboxId: "sbx_test123" }; - it("routes contain all required SandboxRouteData fields", () => { - const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - - for (const route of serialized.routes) { - expect(route).toHaveProperty("url"); - expect(route).toHaveProperty("subdomain"); - expect(route).toHaveProperty("port"); - } + expect(() => FreshSandbox[WORKFLOW_DESERIALIZE](serializedData)).toThrowError( + /Global credentials have not been set/, + ); + expect(() => FreshSandbox[WORKFLOW_DESERIALIZE](serializedData)).toThrowError( + /setGlobalCredentials/, + ); }); - }); - describe("edge cases", () => { - it("handles sandbox with minimal metadata", () => { - const minimalMetadata: SandboxMetaData = { - id: "sbx_minimal", - memory: 1024, - vcpus: 1, - region: "us-west-2", - runtime: "python3.13", - timeout: 60000, - status: "pending", - requestedAt: Date.now(), - createdAt: Date.now(), - cwd: "/", - updatedAt: Date.now(), - }; - - const sandbox = createMockSandbox(minimalMetadata, []); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + it("does not throw when global credentials have been set", async () => { + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); - expect(serialized.sandboxId).toBe("sbx_minimal"); - expect(serialized.metadata.sourceSnapshotId).toBeUndefined(); - expect(serialized.routes).toHaveLength(0); - }); - - it("handles sandbox with maximum ports (4)", () => { - const maxRoutes: SandboxRouteData[] = [ - { url: "https://test-3000.vercel.run", subdomain: "test-3000", port: 3000 }, - { url: "https://test-3001.vercel.run", subdomain: "test-3001", port: 3001 }, - { url: "https://test-3002.vercel.run", subdomain: "test-3002", port: 3002 }, - { url: "https://test-3003.vercel.run", subdomain: "test-3003", port: 3003 }, - ]; - - const sandbox = createMockSandbox(mockMetadata, maxRoutes); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - - expect(serialized.routes).toHaveLength(4); - }); - - it("serialization does not include the API client", () => { - const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); - // Ensure no client-related data is in the serialized output - expect(serialized).not.toHaveProperty("client"); - expect(JSON.stringify(serialized)).not.toContain("token"); - }); + const serializedData: SerializedSandbox = { sandboxId: "sbx_test123" }; - it("handles special characters in sandbox ID", () => { - const metadataWithSpecialId = { ...mockMetadata, id: "sbx_test-123_abc" }; - const sandbox = createMockSandbox(metadataWithSpecialId); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - - expect(serialized.sandboxId).toBe("sbx_test-123_abc"); + expect(() => Sandbox[WORKFLOW_DESERIALIZE](serializedData)).not.toThrow(); }); }); }); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index e7b6a1e..6621aff 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -121,8 +121,6 @@ interface GetSandboxParams { */ export interface SerializedSandbox { sandboxId: string; - metadata: SandboxMetaData; - routes: SandboxRouteData[]; } /** @inline */ @@ -169,13 +167,14 @@ interface RunCommandParams { // Global credentials storage // ============================================================================ -let globalCredentials: Partial = {}; +let globalCredentials: Credentials | null = null; /** * Set global credentials for Sandbox and Command instances. * These credentials are used when lazily creating API clients for deserialized instances. * - * If not called, deserialized instances will use OIDC authentication by default. + * Must be called early in program initialization before using deserialized + * Sandbox or Command instances. * * @param credentials - The credentials to use globally */ @@ -185,10 +184,20 @@ export function setGlobalCredentials(credentials: Credentials): void { /** * Get the global credentials. - * Returns empty object by default (for OIDC authentication). + * Throws if {@link setGlobalCredentials} has not been called. * @internal */ -export function getGlobalCredentials(): Partial { +export function getGlobalCredentials(): Credentials { + if (!globalCredentials) { + throw new Error( + "Global credentials have not been set. " + + "Call `setGlobalCredentials({ token, teamId })` early in your program initialization " + + "before using deserialized Sandbox or Command instances.\n\n" + + "Example:\n" + + " import { setGlobalCredentials } from '@vercel/sandbox';\n" + + " setGlobalCredentials({ token: process.env.VERCEL_TOKEN, teamId: process.env.VERCEL_TEAM_ID });\n", + ); + } return globalCredentials; } @@ -213,8 +222,8 @@ export class Sandbox { if (!this._client) { const credentials = getGlobalCredentials(); this._client = new APIClient({ - teamId: credentials.teamId!, - token: credentials.token!, + teamId: credentials.teamId, + token: credentials.token, }); } return this._client; @@ -341,30 +350,26 @@ export class Sandbox { * Serialize a Sandbox instance to plain data for @workflow/serde. * * @param instance - The Sandbox instance to serialize - * @returns A plain object containing the sandbox ID, metadata, and routes + * @returns A plain object containing the sandbox ID */ static [WORKFLOW_SERIALIZE](instance: Sandbox): SerializedSandbox { return { sandboxId: instance.sandboxId, - metadata: instance.sandbox, - routes: instance.routes, }; } /** - * Deserialize plain data back into a Sandbox instance for @workflow/serde. + * Deserialize a Sandbox by fetching its current state from the API. * - * The deserialized instance will lazily create an API client using global credentials - * when needed. Call {@link Sandbox.setCredentials} before using the deserialized instance. + * Requires global credentials to be set via {@link Sandbox.setCredentials} + * or {@link setGlobalCredentials} before deserialization. * * @param data - The serialized sandbox data - * @returns The reconstructed Sandbox instance + * @returns A promise resolving to a fresh Sandbox instance */ - static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Sandbox { - return new Sandbox({ - sandbox: data.metadata, - routes: data.routes, - }); + static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Promise { + const credentials = getGlobalCredentials(); + return Sandbox.get({ sandboxId: data.sandboxId, ...credentials }); } /** From 7defb61b898a2d0645aaee7be95ac56c7df5c9cc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 1 Mar 2026 06:12:59 -0800 Subject: [PATCH 3/9] Formatting --- packages/vercel-sandbox/package.json | 2 +- packages/vercel-sandbox/src/sandbox.ts | 24 ++++++++++++------------ pnpm-lock.yaml | 10 +++++----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index c6fb753..b7bad1d 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -31,7 +31,7 @@ "license": "Apache-2.0", "dependencies": { "@vercel/oidc": "3.2.0", - "@workflow/serde": "4.0.1-beta.1", + "@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/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 6621aff..8385c5b 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -332,7 +332,7 @@ export class Sandbox { static async list( params?: Partial[0]> & Partial & - WithFetchOptions + WithFetchOptions, ) { const credentials = await getCredentials(params); const client = new APIClient({ @@ -388,7 +388,7 @@ export class Sandbox { params?: WithPrivate< CreateSandboxParams | (CreateSandboxParams & Credentials) > & - WithFetchOptions + WithFetchOptions, ): Promise { const credentials = await getCredentials(params); const client = new APIClient({ @@ -426,7 +426,7 @@ export class Sandbox { */ static async get( params: WithPrivate & - WithFetchOptions + WithFetchOptions, ): Promise { const credentials = await getCredentials(params); const client = new APIClient({ @@ -480,7 +480,7 @@ export class Sandbox { */ async getCommand( cmdId: string, - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ): Promise { const command = await this.client.getCommand({ sandboxId: this.sandbox.id, @@ -507,7 +507,7 @@ export class Sandbox { async runCommand( command: string, args?: string[], - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ): Promise; /** @@ -517,7 +517,7 @@ export class Sandbox { * @returns A {@link Command} instance for the running command. */ async runCommand( - params: RunCommandParams & { detached: true } + params: RunCommandParams & { detached: true }, ): Promise; /** @@ -531,7 +531,7 @@ export class Sandbox { async runCommand( commandOrParams: string | RunCommandParams, args?: string[], - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ): Promise { const params: RunCommandParams = typeof commandOrParams === "string" @@ -635,7 +635,7 @@ export class Sandbox { */ async readFile( file: { path: string; cwd?: string }, - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ): Promise { return this.client.readFile({ sandboxId: this.sandbox.id, @@ -655,7 +655,7 @@ export class Sandbox { */ async readFileToBuffer( file: { path: string; cwd?: string }, - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ): Promise { const stream = await this.client.readFile({ sandboxId: this.sandbox.id, @@ -684,7 +684,7 @@ export class Sandbox { async downloadFile( src: { path: string; cwd?: string }, dst: { path: string; cwd?: string }, - opts?: { mkdirRecursive?: boolean; signal?: AbortSignal } + opts?: { mkdirRecursive?: boolean; signal?: AbortSignal }, ): Promise { if (!src?.path) { throw new Error("downloadFile: source path is required"); @@ -731,7 +731,7 @@ export class Sandbox { */ async writeFiles( files: { path: string; content: Buffer }[], - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ) { return this.client.writeFiles({ sandboxId: this.sandbox.id, @@ -840,7 +840,7 @@ export class Sandbox { */ async extendTimeout( duration: number, - opts?: { signal?: AbortSignal } + opts?: { signal?: AbortSignal }, ): Promise { const response = await this.client.extendTimeout({ sandboxId: this.sandbox.id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f2f3e7..317cd8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,8 +338,8 @@ importers: specifier: 3.2.0 version: 3.2.0 '@workflow/serde': - specifier: 4.0.1-beta.1 - version: 4.0.1-beta.1 + specifier: 4.1.0-beta.2 + version: 4.1.0-beta.2 async-retry: specifier: 1.3.3 version: 1.3.3 @@ -1581,8 +1581,8 @@ packages: '@vitest/utils@3.2.1': resolution: {integrity: sha512-KkHlGhePEKZSub5ViknBcN5KEF+u7dSUr9NW8QsVICusUojrgrOnnY3DEWWO877ax2Pyopuk2qHmt+gkNKnBVw==} - '@workflow/serde@4.0.1-beta.1': - resolution: {integrity: sha512-JdCvZk0sD1qk2XzteSHVNGbpRM8YcvRBglSJy3FysYgi19pBkk3me2P9gzFpPrnQS6IhAPnEqnJOAODHg1iZrA==} + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} @@ -4900,7 +4900,7 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@workflow/serde@4.0.1-beta.1': {} + '@workflow/serde@4.1.0-beta.2': {} acorn-walk@8.3.4: dependencies: From 26397e54f1c34f3f4fb000d89d5a342c76245f44 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 1 Mar 2026 06:30:46 -0800 Subject: [PATCH 4/9] e2e test --- packages/vercel-sandbox/package.json | 1 + .../src/command.serialize.test.ts | 53 +- .../src/sandbox.serialize.test.ts | 44 + pnpm-lock.yaml | 996 +++++++++++++++++- 4 files changed, 1079 insertions(+), 15 deletions(-) diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index b7bad1d..5fea063 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -47,6 +47,7 @@ "@types/ms": "2.1.0", "@types/node": "22.15.12", "@types/tar-stream": "3.1.4", + "@workflow/core": "4.1.0-beta.62", "dotenv": "16.5.0", "factoree": "^0.1.2", "typedoc": "0.28.5", diff --git a/packages/vercel-sandbox/src/command.serialize.test.ts b/packages/vercel-sandbox/src/command.serialize.test.ts index c8e00ae..138f256 100644 --- a/packages/vercel-sandbox/src/command.serialize.test.ts +++ b/packages/vercel-sandbox/src/command.serialize.test.ts @@ -1,8 +1,13 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE, } from "@workflow/serde"; +import { registerSerializationClass } from "@workflow/core/class-serialization"; +import { + dehydrateStepReturnValue, + hydrateStepReturnValue, +} from "@workflow/core/serialization"; import { Command, CommandFinished, @@ -419,4 +424,50 @@ describe("CommandFinished serialization", () => { expect(waited).toBe(commandFinished); }); }); + + describe("workflow runtime integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("CommandFinished survives a step boundary roundtrip", async () => { + registerSerializationClass("CommandFinished", CommandFinished); + + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 0, + mockOutput, + ); + + // Simulate step returning a CommandFinished + const dehydrated = await dehydrateStepReturnValue(commandFinished, "run_123", undefined); + expect(dehydrated).toBeInstanceOf(Uint8Array); + + // Simulate workflow receiving the step result + const rehydrated = await hydrateStepReturnValue(dehydrated, "run_123", undefined); + + expect(rehydrated).toBeInstanceOf(CommandFinished); + expect(rehydrated.exitCode).toBe(0); + expect(rehydrated.cmdId).toBe(mockCommandData.id); + }); + + it("preserves output through the runtime pipeline", async () => { + registerSerializationClass("CommandFinished", CommandFinished); + + const commandFinished = createMockCommandFinished( + mockCommandData, + mockSandboxId, + 42, + mockOutput, + ); + + const dehydrated = await dehydrateStepReturnValue(commandFinished, "run_456", undefined); + const rehydrated = await hydrateStepReturnValue(dehydrated, "run_456", undefined); + + expect(rehydrated.exitCode).toBe(42); + expect(await rehydrated.stdout()).toBe(mockOutput.stdout); + expect(await rehydrated.stderr()).toBe(mockOutput.stderr); + }); + }); }); diff --git a/packages/vercel-sandbox/src/sandbox.serialize.test.ts b/packages/vercel-sandbox/src/sandbox.serialize.test.ts index 6b501dc..d23315a 100644 --- a/packages/vercel-sandbox/src/sandbox.serialize.test.ts +++ b/packages/vercel-sandbox/src/sandbox.serialize.test.ts @@ -3,6 +3,11 @@ import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE, } from "@workflow/serde"; +import { registerSerializationClass } from "@workflow/core/class-serialization"; +import { + dehydrateStepReturnValue, + hydrateStepReturnValue, +} from "@workflow/core/serialization"; import { Sandbox, SerializedSandbox, setGlobalCredentials } from "./sandbox"; import type { SandboxMetaData, SandboxRouteData } from "./api-client"; import { APIClient } from "./api-client"; @@ -221,4 +226,43 @@ describe("Sandbox serialization", () => { expect(() => Sandbox[WORKFLOW_DESERIALIZE](serializedData)).not.toThrow(); }); }); + + describe("workflow runtime integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("Sandbox survives a step boundary roundtrip", async () => { + registerSerializationClass("Sandbox", Sandbox); + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + + const sandbox = createMockSandbox(); + vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + + // Simulate step returning a Sandbox + const dehydrated = await dehydrateStepReturnValue(sandbox, "run_123", undefined); + expect(dehydrated).toBeInstanceOf(Uint8Array); + + // Simulate workflow receiving the step result + const rehydrated = await hydrateStepReturnValue(dehydrated, "run_123", undefined); + + expect(rehydrated).toBeInstanceOf(Sandbox); + expect(rehydrated.sandboxId).toBe(sandbox.sandboxId); + }); + + it("preserves sandbox properties through the runtime pipeline", async () => { + registerSerializationClass("Sandbox", Sandbox); + setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + + const sandbox = createMockSandbox(); + vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); + + const dehydrated = await dehydrateStepReturnValue(sandbox, "run_456", undefined); + const rehydrated = await hydrateStepReturnValue(dehydrated, "run_456", undefined); + + expect(rehydrated.sandboxId).toBe("sbx_test123"); + expect(rehydrated.status).toBe("running"); + expect(rehydrated.routes).toEqual(mockRoutes); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 317cd8c..634fa87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,13 +164,13 @@ importers: dependencies: '@ai-sdk/gateway': specifier: 1.0.0-beta.4 - version: 1.0.0-beta.4(zod@4.1.5) + version: 1.0.0-beta.4(zod@4.3.6) '@vercel/sandbox': specifier: workspace:* version: link:../../packages/vercel-sandbox ai: specifier: 5.0.0-beta.12 - version: 5.0.0-beta.12(zod@4.1.5) + version: 5.0.0-beta.12(zod@4.3.6) devDependencies: '@types/node': specifier: ^20 @@ -380,6 +380,9 @@ importers: '@types/tar-stream': specifier: 3.1.4 version: 3.1.4 + '@workflow/core': + specifier: 4.1.0-beta.62 + version: 4.1.0-beta.62(@opentelemetry/api@1.9.0) dotenv: specifier: 16.5.0 version: 16.5.0 @@ -462,6 +465,83 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/core@3.973.15': + resolution: {integrity: sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.13': + resolution: {integrity: sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.6': + resolution: {integrity: sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.6': + resolution: {integrity: sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.6': + resolution: {integrity: sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.15': + resolution: {integrity: sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.3': + resolution: {integrity: sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.6': + resolution: {integrity: sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.4': + resolution: {integrity: sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.3': + resolution: {integrity: sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.6': + resolution: {integrity: sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==} + + '@aws-sdk/util-user-agent-node@3.973.0': + resolution: {integrity: sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.8': + resolution: {integrity: sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -495,6 +575,36 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + resolution: {integrity: sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + resolution: {integrity: sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + resolution: {integrity: sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + resolution: {integrity: sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + resolution: {integrity: sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==} + cpu: [x64] + os: [win32] + '@changesets/apply-release-plan@7.0.12': resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} @@ -1335,6 +1445,178 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/abort-controller@4.2.10': + resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.9': + resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.6': + resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.10': + resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.11': + resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.10': + resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.10': + resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.1': + resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.10': + resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.20': + resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.37': + resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.11': + resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.10': + resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.10': + resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.12': + resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.10': + resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.10': + resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.10': + resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.10': + resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.10': + resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.5': + resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.10': + resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.0': + resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.13.0': + resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.10': + resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.1': + resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.1': + resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.2': + resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.1': + resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.1': + resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.36': + resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.39': + resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.3.1': + resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.1': + resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.10': + resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.10': + resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.15': + resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.1': + resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.1': + resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.1': + resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -1545,10 +1827,23 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vercel/functions@3.4.3': + resolution: {integrity: sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/queue@0.1.1': + resolution: {integrity: sha512-ozO0tSBXUYN4gUkK65GbcqgxpC55qaaiY9MzNuXW4cvOSJ5nCkcgO+DQXcfyfL7h+0uIC5HTcP0mPvQ3dW3EhQ==} + engines: {node: '>=20.0.0'} + '@vitest/expect@3.2.1': resolution: {integrity: sha512-FqS/BnDOzV6+IpxrTg5GQRyLOCtcJqkwMwcS8qGCI2IyRVDwPAtutztaf1CjtPHlZlWtl1yUPCd7HM0cNiDOYw==} @@ -1581,9 +1876,44 @@ packages: '@vitest/utils@3.2.1': resolution: {integrity: sha512-KkHlGhePEKZSub5ViknBcN5KEF+u7dSUr9NW8QsVICusUojrgrOnnY3DEWWO877ax2Pyopuk2qHmt+gkNKnBVw==} + '@workflow/core@4.1.0-beta.62': + resolution: {integrity: sha512-ovq908bMRCcy81DUCQwp6MCMczEdhxxOW7Z3nnNQZ13yOsYa5wzJuH2+Y4GtfSr0jbeDr/t472X6aumnVCDRIg==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/errors@4.1.0-beta.17': + resolution: {integrity: sha512-ctDx9PrTCAkfsGqs6PgYAMGSaOmHESTMJEdj+d+RU0qEDfXWBZmM586hkf9hXw3jwXnw0VMp9X01jLsnWPyZcA==} + '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + '@workflow/utils@4.1.0-beta.13': + resolution: {integrity: sha512-3vVuXZVfLVeJ78MM6D0gNXg6hMZdDYAzmF92p+HxItI0B2Yk1EuDIIUfBXKWwTOKCCuKF4iroZt2u9BFqrs2AQ==} + + '@workflow/world-local@4.1.0-beta.36': + resolution: {integrity: sha512-eOavTINKlpepB4MJyQr0RoUVRnFZM3nP4T+AUMrRjm9qhZvZB5g5TgHBfkrOzdqqOHtuj7dfOccNc76hywZ5Fw==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world-vercel@4.1.0-beta.36': + resolution: {integrity: sha512-YF22Kg2tnyyFFEh+3BsvQWmbni/wtJQTwH/OQRenNuY155L1EvnMjDdqxKthuWpK2iL3i768TOB7x+EqE3spZA==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world@4.1.0-beta.8': + resolution: {integrity: sha512-zzN0cGqjg0fBI0vlufEW8wz/Rl1vJyGKpy8KQSYAqjBEaHCqey6+2/YPZyQzFR0X2jqxcc45yHw8e3qHjsG1+A==} + peerDependencies: + zod: 4.3.6 + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -1693,6 +2023,9 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -1712,6 +2045,9 @@ packages: birpc@2.8.0: resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1749,6 +2085,13 @@ packages: caniuse-lite@1.0.30001739: resolution: {integrity: sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==} + cbor-extract@2.2.0: + resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1970,6 +2313,9 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2108,6 +2454,10 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-xml-parser@5.3.6: + resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -2816,6 +3166,10 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} + mixpart@0.0.5: + resolution: {integrity: sha512-TpWi9/2UIr7VWCVAM7NB4WR4yOglAetBkuKfxs3K0vFcUukqAaW1xsgX0v1gNGiDKzYhPHFcHgarC7jmnaOy4w==} + engines: {node: '>=20.0.0'} + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -2833,6 +3187,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + next@15.3.6: resolution: {integrity: sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -2867,6 +3226,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -3241,6 +3604,9 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -3410,6 +3776,9 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strnum@2.2.0: + resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} + style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} @@ -3638,6 +4007,10 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ulid@3.0.1: + resolution: {integrity: sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q==} + hasBin: true + unconfig-core@7.4.1: resolution: {integrity: sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA==} @@ -3651,6 +4024,10 @@ packages: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -3865,6 +4242,9 @@ packages: zod@4.1.5: resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3876,11 +4256,11 @@ snapshots: '@ai-sdk/provider-utils': 3.0.0-beta.1(zod@3.25.67) zod: 3.25.67 - '@ai-sdk/gateway@1.0.0-beta.4(zod@4.1.5)': + '@ai-sdk/gateway@1.0.0-beta.4(zod@4.3.6)': dependencies: '@ai-sdk/provider': 2.0.0-beta.1 - '@ai-sdk/provider-utils': 3.0.0-beta.2(zod@4.1.5) - zod: 4.1.5 + '@ai-sdk/provider-utils': 3.0.0-beta.2(zod@4.3.6) + zod: 4.3.6 '@ai-sdk/openai-compatible@1.0.0-alpha.7(zod@3.25.67)': dependencies: @@ -3903,13 +4283,13 @@ snapshots: zod: 3.25.67 zod-to-json-schema: 3.24.6(zod@3.25.67) - '@ai-sdk/provider-utils@3.0.0-beta.2(zod@4.1.5)': + '@ai-sdk/provider-utils@3.0.0-beta.2(zod@4.3.6)': dependencies: '@ai-sdk/provider': 2.0.0-beta.1 '@standard-schema/spec': 1.0.0 eventsource-parser: 3.0.6 - zod: 4.1.5 - zod-to-json-schema: 3.24.6(zod@4.1.5) + zod: 4.3.6 + zod-to-json-schema: 3.24.6(zod@4.3.6) '@ai-sdk/provider@2.0.0-alpha.7': dependencies: @@ -3938,6 +4318,182 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.4 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.4 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.973.15': + dependencies: + '@aws-sdk/types': 3.973.4 + '@aws-sdk/xml-builder': 3.972.8 + '@smithy/core': 3.23.6 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.13': + dependencies: + '@aws-sdk/core': 3.973.15 + '@aws-sdk/nested-clients': 3.996.3 + '@aws-sdk/types': 3.973.4 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-host-header@3.972.6': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.6': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.6': + dependencies: + '@aws-sdk/types': 3.973.4 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.15': + dependencies: + '@aws-sdk/core': 3.973.15 + '@aws-sdk/types': 3.973.4 + '@aws-sdk/util-endpoints': 3.996.3 + '@smithy/core': 3.23.6 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.3': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.15 + '@aws-sdk/middleware-host-header': 3.972.6 + '@aws-sdk/middleware-logger': 3.972.6 + '@aws-sdk/middleware-recursion-detection': 3.972.6 + '@aws-sdk/middleware-user-agent': 3.972.15 + '@aws-sdk/region-config-resolver': 3.972.6 + '@aws-sdk/types': 3.973.4 + '@aws-sdk/util-endpoints': 3.996.3 + '@aws-sdk/util-user-agent-browser': 3.972.6 + '@aws-sdk/util-user-agent-node': 3.973.0 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.6': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/config-resolver': 4.4.9 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.4': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.3': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-endpoints': 3.3.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.6': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/types': 4.13.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.15 + '@aws-sdk/types': 3.973.4 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.8': + dependencies: + '@smithy/types': 4.13.0 + fast-xml-parser: 5.3.6 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -3969,6 +4525,24 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + optional: true + '@changesets/apply-release-plan@7.0.12': dependencies: '@changesets/config': 3.1.1 @@ -4648,6 +5222,280 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/abort-controller@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.9': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + + '@smithy/core@3.23.6': + dependencies: + '@smithy/middleware-serde': 4.2.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.10': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.10': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.20': + dependencies: + '@smithy/core': 3.23.6 + '@smithy/middleware-serde': 4.2.11 + '@smithy/node-config-provider': 4.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.37': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/service-error-classification': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.10': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.12': + dependencies: + '@smithy/abort-controller': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.1 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + + '@smithy/shared-ini-file-loader@4.4.5': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.10': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-uri-escape': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.0': + dependencies: + '@smithy/core': 3.23.6 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-stack': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 + tslib: 2.8.1 + + '@smithy/types@4.13.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.10': + dependencies: + '@smithy/querystring-parser': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.1': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.36': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.39': + dependencies: + '@smithy/config-resolver': 4.4.9 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.3.1': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.10': + dependencies: + '@smithy/service-error-classification': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.15': + dependencies: + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.1': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.0.0': {} '@standard-schema/utils@0.3.0': {} @@ -4845,8 +5693,19 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13)': + dependencies: + '@vercel/oidc': 3.2.0 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.13 + '@vercel/oidc@3.2.0': {} + '@vercel/queue@0.1.1': + dependencies: + '@vercel/oidc': 3.2.0 + mixpart: 0.0.5 + '@vitest/expect@3.2.1': dependencies: '@types/chai': 5.2.2 @@ -4900,8 +5759,72 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@workflow/core@4.1.0-beta.62(@opentelemetry/api@1.9.0)': + dependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.13 + '@jridgewell/trace-mapping': 0.3.31 + '@standard-schema/spec': 1.0.0 + '@types/ms': 2.1.0 + '@vercel/functions': 3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13) + '@workflow/errors': 4.1.0-beta.17 + '@workflow/serde': 4.1.0-beta.2 + '@workflow/utils': 4.1.0-beta.13 + '@workflow/world': 4.1.0-beta.8(zod@4.3.6) + '@workflow/world-local': 4.1.0-beta.36(@opentelemetry/api@1.9.0) + '@workflow/world-vercel': 4.1.0-beta.36(@opentelemetry/api@1.9.0) + debug: 4.4.3 + devalue: 5.6.3 + ms: 2.1.3 + nanoid: 5.1.6 + seedrandom: 3.0.5 + ulid: 3.0.1 + zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - aws-crt + - supports-color + + '@workflow/errors@4.1.0-beta.17': + dependencies: + '@workflow/utils': 4.1.0-beta.13 + ms: 2.1.3 + '@workflow/serde@4.1.0-beta.2': {} + '@workflow/utils@4.1.0-beta.13': + dependencies: + ms: 2.1.3 + + '@workflow/world-local@4.1.0-beta.36(@opentelemetry/api@1.9.0)': + dependencies: + '@vercel/queue': 0.1.1 + '@workflow/errors': 4.1.0-beta.17 + '@workflow/utils': 4.1.0-beta.13 + '@workflow/world': 4.1.0-beta.8(zod@4.3.6) + async-sema: 3.1.1 + ulid: 3.0.1 + undici: 7.22.0 + zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + + '@workflow/world-vercel@4.1.0-beta.36(@opentelemetry/api@1.9.0)': + dependencies: + '@vercel/oidc': 3.2.0 + '@vercel/queue': 0.1.1 + '@workflow/errors': 4.1.0-beta.17 + '@workflow/world': 4.1.0-beta.8(zod@4.3.6) + cbor-x: 1.6.0 + undici: 7.22.0 + zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + + '@workflow/world@4.1.0-beta.8(zod@4.3.6)': + dependencies: + zod: 4.3.6 + acorn-walk@8.3.4: dependencies: acorn: 8.15.0 @@ -4921,13 +5844,13 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.67 - ai@5.0.0-beta.12(zod@4.1.5): + ai@5.0.0-beta.12(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 1.0.0-beta.4(zod@4.1.5) + '@ai-sdk/gateway': 1.0.0-beta.4(zod@4.3.6) '@ai-sdk/provider': 2.0.0-beta.1 - '@ai-sdk/provider-utils': 3.0.0-beta.2(zod@4.1.5) + '@ai-sdk/provider-utils': 3.0.0-beta.2(zod@4.3.6) '@opentelemetry/api': 1.9.0 - zod: 4.1.5 + zod: 4.3.6 ansi-colors@4.1.3: {} @@ -4982,6 +5905,8 @@ snapshots: dependencies: retry: 0.13.1 + async-sema@3.1.1: {} + b4a@1.6.7: {} bail@2.0.2: {} @@ -4997,6 +5922,8 @@ snapshots: birpc@2.8.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5032,6 +5959,22 @@ snapshots: caniuse-lite@1.0.30001739: {} + cbor-extract@2.2.0: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.0 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.0 + '@cbor-extract/cbor-extract-linux-x64': 2.2.0 + '@cbor-extract/cbor-extract-win32-x64': 2.2.0 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.0 + ccount@2.0.1: {} chai@5.3.3: @@ -5236,6 +6179,8 @@ snapshots: detect-libc@2.0.4: {} + devalue@5.6.3: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -5381,6 +6326,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-xml-parser@5.3.6: + dependencies: + strnum: 2.2.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -6327,6 +7276,8 @@ snapshots: dependencies: minipass: 7.1.2 + mixpart@0.0.5: {} + mkdirp@3.0.1: {} mri@1.2.0: {} @@ -6335,6 +7286,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + next@15.3.6(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@next/env': 15.3.6 @@ -6367,6 +7320,11 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.0.4 + optional: true + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -6792,6 +7750,8 @@ snapshots: scheduler@0.26.0: {} + seedrandom@3.0.5: {} + semver-compare@1.0.0: {} semver@5.7.2: {} @@ -6967,6 +7927,8 @@ snapshots: strip-final-newline@2.0.0: {} + strnum@2.2.0: {} + style-to-js@1.1.17: dependencies: style-to-object: 1.0.9 @@ -7166,6 +8128,8 @@ snapshots: uc.micro@2.1.0: {} + ulid@3.0.1: {} + unconfig-core@7.4.1: dependencies: '@quansync/fs': 0.1.5 @@ -7177,6 +8141,8 @@ snapshots: undici@7.16.0: {} + undici@7.22.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -7462,9 +8428,9 @@ snapshots: dependencies: zod: 3.25.67 - zod-to-json-schema@3.24.6(zod@4.1.5): + zod-to-json-schema@3.24.6(zod@4.3.6): dependencies: - zod: 4.1.5 + zod: 4.3.6 zod@3.24.4: {} @@ -7472,4 +8438,6 @@ snapshots: zod@4.1.5: {} + zod@4.3.6: {} + zwitch@2.0.4: {} From 6577fb8a724876c7a9b55e84b91926abfd48f9bc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 1 Mar 2026 06:39:33 -0800 Subject: [PATCH 5/9] rename --- README.md | 20 ++++++++++++++++ packages/vercel-sandbox/README.md | 20 ++++++++++++++++ packages/vercel-sandbox/src/command.ts | 4 ++-- packages/vercel-sandbox/src/index.ts | 1 + .../src/sandbox.serialize.test.ts | 24 +++++++++---------- packages/vercel-sandbox/src/sandbox.ts | 20 ++++++++-------- 6 files changed, 65 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b6787fc..fe40088 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,26 @@ const sandbox = await Sandbox.create({ }); ``` +## Workflow DevKit integration + +`Sandbox` and `CommandFinished` support serialization with the +[Workflow DevKit](https://vercel.com/docs/workflow). When a sandbox instance +crosses a step boundary the SDK serializes only the sandbox ID, then +rehydrates a fresh instance on the other side by calling `Sandbox.get`. + +Because the workflow runtime deserializes in a new execution context, +credentials are not carried over. Call `setSandboxCredentials` early in your +program so that deserialized instances can reconnect to the API: + +```ts +import { Sandbox, setSandboxCredentials } from "@vercel/sandbox"; + +setSandboxCredentials({ + token: process.env.VERCEL_TOKEN!, + teamId: process.env.VERCEL_TEAM_ID!, +}); +``` + ## Limitations - Max resources: 8 vCPUs. You will get 2048 MB of memory per vCPU. diff --git a/packages/vercel-sandbox/README.md b/packages/vercel-sandbox/README.md index 0732622..8d23521 100644 --- a/packages/vercel-sandbox/README.md +++ b/packages/vercel-sandbox/README.md @@ -142,6 +142,26 @@ const sandbox = await Sandbox.create({ }); ``` +## Workflow DevKit integration + +`Sandbox` and `CommandFinished` support serialization with the +[Workflow DevKit](https://vercel.com/docs/workflow). When a sandbox instance +crosses a step boundary the SDK serializes only the sandbox ID, then +rehydrates a fresh instance on the other side by calling `Sandbox.get`. + +Because the workflow runtime deserializes in a new execution context, +credentials are not carried over. Call `setSandboxCredentials` early in your +program so that deserialized instances can reconnect to the API: + +```ts +import { Sandbox, setSandboxCredentials } from "@vercel/sandbox"; + +setSandboxCredentials({ + token: process.env.VERCEL_TOKEN!, + teamId: process.env.VERCEL_TEAM_ID!, +}); +``` + ## Limitations - Max resources: 8 vCPUs. You will get 2048 MB of memory per vCPU. diff --git a/packages/vercel-sandbox/src/command.ts b/packages/vercel-sandbox/src/command.ts index b5e75b2..724b839 100644 --- a/packages/vercel-sandbox/src/command.ts +++ b/packages/vercel-sandbox/src/command.ts @@ -1,6 +1,6 @@ import { APIClient, type CommandData } from "./api-client"; import { Signal, resolveSignal } from "./utils/resolveSignal"; -import { getGlobalCredentials } from "./sandbox"; +import { getSandboxCredentials } from "./sandbox"; import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; /** @@ -54,7 +54,7 @@ export class Command { */ get client(): APIClient { if (!this._client) { - const credentials = getGlobalCredentials(); + const credentials = getSandboxCredentials(); this._client = new APIClient({ teamId: credentials.teamId, token: credentials.token, diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 1a187a4..8c344cf 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -1,5 +1,6 @@ export { Sandbox, + setSandboxCredentials, type NetworkPolicy, type NetworkPolicyRule, type NetworkTransformer, diff --git a/packages/vercel-sandbox/src/sandbox.serialize.test.ts b/packages/vercel-sandbox/src/sandbox.serialize.test.ts index d23315a..3ef0550 100644 --- a/packages/vercel-sandbox/src/sandbox.serialize.test.ts +++ b/packages/vercel-sandbox/src/sandbox.serialize.test.ts @@ -8,7 +8,7 @@ import { dehydrateStepReturnValue, hydrateStepReturnValue, } from "@workflow/core/serialization"; -import { Sandbox, SerializedSandbox, setGlobalCredentials } from "./sandbox"; +import { Sandbox, SerializedSandbox, setSandboxCredentials } from "./sandbox"; import type { SandboxMetaData, SandboxRouteData } from "./api-client"; import { APIClient } from "./api-client"; @@ -108,7 +108,7 @@ describe("Sandbox serialization", () => { }); it("calls Sandbox.get with the serialized sandbox ID", async () => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const mockSandbox = createMockSandbox(); const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(mockSandbox); @@ -123,7 +123,7 @@ describe("Sandbox serialization", () => { }); it("returns a promise", () => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); @@ -133,7 +133,7 @@ describe("Sandbox serialization", () => { }); it("passes global credentials to Sandbox.get", async () => { - setGlobalCredentials({ token: "my_token", teamId: "my_team" }); + setSandboxCredentials({ token: "my_token", teamId: "my_team" }); const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); @@ -149,7 +149,7 @@ describe("Sandbox serialization", () => { }); it("returns a fully functional Sandbox instance", async () => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const mockSandbox = createMockSandbox(); vi.spyOn(Sandbox, "get").mockResolvedValue(mockSandbox); @@ -169,7 +169,7 @@ describe("Sandbox serialization", () => { }); it("preserves sandboxId through roundtrip", async () => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const originalSandbox = createMockSandbox(); vi.spyOn(Sandbox, "get").mockResolvedValue(originalSandbox); @@ -182,7 +182,7 @@ describe("Sandbox serialization", () => { }); it("serialized data can be stored and retrieved via JSON", async () => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const originalSandbox = createMockSandbox(); vi.spyOn(Sandbox, "get").mockResolvedValue(originalSandbox); @@ -199,7 +199,7 @@ describe("Sandbox serialization", () => { describe("global credentials error", () => { afterEach(() => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); }); it("throws a helpful error when deserializing without global credentials", async () => { @@ -212,12 +212,12 @@ describe("Sandbox serialization", () => { /Global credentials have not been set/, ); expect(() => FreshSandbox[WORKFLOW_DESERIALIZE](serializedData)).toThrowError( - /setGlobalCredentials/, + /setSandboxCredentials/, ); }); it("does not throw when global credentials have been set", async () => { - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); @@ -234,7 +234,7 @@ describe("Sandbox serialization", () => { it("Sandbox survives a step boundary roundtrip", async () => { registerSerializationClass("Sandbox", Sandbox); - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const sandbox = createMockSandbox(); vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); @@ -252,7 +252,7 @@ describe("Sandbox serialization", () => { it("preserves sandbox properties through the runtime pipeline", async () => { registerSerializationClass("Sandbox", Sandbox); - setGlobalCredentials({ token: "test_token", teamId: "team_test" }); + setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const sandbox = createMockSandbox(); vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 8385c5b..ea46488 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -178,24 +178,24 @@ let globalCredentials: Credentials | null = null; * * @param credentials - The credentials to use globally */ -export function setGlobalCredentials(credentials: Credentials): void { +export function setSandboxCredentials(credentials: Credentials): void { globalCredentials = credentials; } /** * Get the global credentials. - * Throws if {@link setGlobalCredentials} has not been called. + * Throws if {@link setSandboxCredentials} has not been called. * @internal */ -export function getGlobalCredentials(): Credentials { +export function getSandboxCredentials(): Credentials { if (!globalCredentials) { throw new Error( "Global credentials have not been set. " + - "Call `setGlobalCredentials({ token, teamId })` early in your program initialization " + + "Call `setSandboxCredentials({ token, teamId })` early in your program initialization " + "before using deserialized Sandbox or Command instances.\n\n" + "Example:\n" + - " import { setGlobalCredentials } from '@vercel/sandbox';\n" + - " setGlobalCredentials({ token: process.env.VERCEL_TOKEN, teamId: process.env.VERCEL_TEAM_ID });\n", + " import { setSandboxCredentials } from '@vercel/sandbox';\n" + + " setSandboxCredentials({ token: process.env.VERCEL_TOKEN, teamId: process.env.VERCEL_TEAM_ID });\n", ); } return globalCredentials; @@ -220,7 +220,7 @@ export class Sandbox { */ get client(): APIClient { if (!this._client) { - const credentials = getGlobalCredentials(); + const credentials = getSandboxCredentials(); this._client = new APIClient({ teamId: credentials.teamId, token: credentials.token, @@ -321,7 +321,7 @@ export class Sandbox { * await sandbox.runCommand('echo', ['hello']); */ static setCredentials(credentials: Credentials): void { - setGlobalCredentials(credentials); + setSandboxCredentials(credentials); } /** @@ -362,13 +362,13 @@ export class Sandbox { * Deserialize a Sandbox by fetching its current state from the API. * * Requires global credentials to be set via {@link Sandbox.setCredentials} - * or {@link setGlobalCredentials} before deserialization. + * or {@link setSandboxCredentials} before deserialization. * * @param data - The serialized sandbox data * @returns A promise resolving to a fresh Sandbox instance */ static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Promise { - const credentials = getGlobalCredentials(); + const credentials = getSandboxCredentials(); return Sandbox.get({ sandboxId: data.sandboxId, ...credentials }); } From f2d40504ba18ac00136ec49ea7ee472290306c70 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 1 Mar 2026 06:40:23 -0800 Subject: [PATCH 6/9] changeset --- .changeset/icy-nights-bet.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/icy-nights-bet.md diff --git a/.changeset/icy-nights-bet.md b/.changeset/icy-nights-bet.md new file mode 100644 index 0000000..2f905ee --- /dev/null +++ b/.changeset/icy-nights-bet.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": patch +--- + +Support useworkflow serialization for sandboxes and commands From f4efc7e89c684de458b793ef0d48f449dd49a340 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 1 Mar 2026 07:04:09 -0800 Subject: [PATCH 7/9] comments --- README.md | 4 +- packages/vercel-sandbox/README.md | 4 +- packages/vercel-sandbox/src/command.ts | 2 +- packages/vercel-sandbox/src/sandbox.ts | 43 +++---------------- .../src/utils/sandbox-credentials.ts | 38 ++++++++++++++++ 5 files changed, 49 insertions(+), 42 deletions(-) create mode 100644 packages/vercel-sandbox/src/utils/sandbox-credentials.ts diff --git a/README.md b/README.md index fe40088..1d92d1c 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ crosses a step boundary the SDK serializes only the sandbox ID, then rehydrates a fresh instance on the other side by calling `Sandbox.get`. Because the workflow runtime deserializes in a new execution context, -credentials are not carried over. Call `setSandboxCredentials` early in your -program so that deserialized instances can reconnect to the API: +credentials are not carried over. Call `setSandboxCredentials` in the module +scope so that deserialized instances can reconnect to the API: ```ts import { Sandbox, setSandboxCredentials } from "@vercel/sandbox"; diff --git a/packages/vercel-sandbox/README.md b/packages/vercel-sandbox/README.md index 8d23521..93d33bf 100644 --- a/packages/vercel-sandbox/README.md +++ b/packages/vercel-sandbox/README.md @@ -150,8 +150,8 @@ crosses a step boundary the SDK serializes only the sandbox ID, then rehydrates a fresh instance on the other side by calling `Sandbox.get`. Because the workflow runtime deserializes in a new execution context, -credentials are not carried over. Call `setSandboxCredentials` early in your -program so that deserialized instances can reconnect to the API: +credentials are not carried over. Call `setSandboxCredentials` in the module +scope so that deserialized instances can reconnect to the API: ```ts import { Sandbox, setSandboxCredentials } from "@vercel/sandbox"; diff --git a/packages/vercel-sandbox/src/command.ts b/packages/vercel-sandbox/src/command.ts index 724b839..5f88ff8 100644 --- a/packages/vercel-sandbox/src/command.ts +++ b/packages/vercel-sandbox/src/command.ts @@ -1,6 +1,6 @@ import { APIClient, type CommandData } from "./api-client"; import { Signal, resolveSignal } from "./utils/resolveSignal"; -import { getSandboxCredentials } from "./sandbox"; +import { getSandboxCredentials } from "./utils/sandbox-credentials"; import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; /** diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index ea46488..3f31fd1 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -12,6 +12,10 @@ import { WithFetchOptions } from "./api-client/api-client"; import { RUNTIMES } from "./constants"; import { Snapshot } from "./snapshot"; import { consumeReadable } from "./utils/consume-readable"; +import { + setSandboxCredentials, + getSandboxCredentials, +} from "./utils/sandbox-credentials"; import { type NetworkPolicy, type NetworkPolicyRule, @@ -163,43 +167,8 @@ interface RunCommandParams { signal?: AbortSignal; } -// ============================================================================ -// Global credentials storage -// ============================================================================ - -let globalCredentials: Credentials | null = null; - -/** - * Set global credentials for Sandbox and Command instances. - * These credentials are used when lazily creating API clients for deserialized instances. - * - * Must be called early in program initialization before using deserialized - * Sandbox or Command instances. - * - * @param credentials - The credentials to use globally - */ -export function setSandboxCredentials(credentials: Credentials): void { - globalCredentials = credentials; -} - -/** - * Get the global credentials. - * Throws if {@link setSandboxCredentials} has not been called. - * @internal - */ -export function getSandboxCredentials(): Credentials { - if (!globalCredentials) { - throw new Error( - "Global credentials have not been set. " + - "Call `setSandboxCredentials({ token, teamId })` early in your program initialization " + - "before using deserialized Sandbox or Command instances.\n\n" + - "Example:\n" + - " import { setSandboxCredentials } from '@vercel/sandbox';\n" + - " setSandboxCredentials({ token: process.env.VERCEL_TOKEN, teamId: process.env.VERCEL_TEAM_ID });\n", - ); - } - return globalCredentials; -} +// Re-export for public API +export { setSandboxCredentials } from "./utils/sandbox-credentials"; // ============================================================================ // Sandbox class diff --git a/packages/vercel-sandbox/src/utils/sandbox-credentials.ts b/packages/vercel-sandbox/src/utils/sandbox-credentials.ts new file mode 100644 index 0000000..e7e7674 --- /dev/null +++ b/packages/vercel-sandbox/src/utils/sandbox-credentials.ts @@ -0,0 +1,38 @@ +import pico from "picocolors"; +import type { Credentials } from "./get-credentials"; + +let sandboxCredentials: Credentials | null = null; + +/** + * Set global credentials for Sandbox and Command instances. + * These credentials are used when lazily creating API clients for deserialized instances. + * + * Must be called in the module scope before using deserialized + * Sandbox or Command instances. + * + * @param credentials - The credentials to use globally + */ +export function setSandboxCredentials(credentials: Credentials): void { + sandboxCredentials = credentials; +} + +/** + * Get the global credentials. + * Throws if {@link setSandboxCredentials} has not been called. + * @internal + */ +export function getSandboxCredentials(): Credentials { + if (!sandboxCredentials) { + throw new Error( + [ + `Global credentials have not been set.`, + `${pico.bold("hint:")} Call setSandboxCredentials() in the module scope before using deserialized instances.`, + "├▶ Docs: https://vercel.com/docs/vercel-sandbox", + "╰▶ Example:", + " import { setSandboxCredentials } from '@vercel/sandbox';", + " setSandboxCredentials({ token: process.env.VERCEL_TOKEN, teamId: process.env.VERCEL_TEAM_ID });", + ].join("\n"), + ); + } + return sandboxCredentials; +} From 32ac1ffb3239971dc2bab6b1bdbcc5fc9f944563 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Mon, 2 Mar 2026 12:38:10 -0500 Subject: [PATCH 8/9] refactor sandbox serde to use snapshot-based sync rehydration --- README.md | 6 +- packages/vercel-sandbox/README.md | 6 +- .../src/sandbox.serialize.test.ts | 274 ++++++++---------- packages/vercel-sandbox/src/sandbox.ts | 58 ++-- ...convert-sandbox.ts => sandbox-snapshot.ts} | 4 +- 5 files changed, 158 insertions(+), 190 deletions(-) rename packages/vercel-sandbox/src/utils/{convert-sandbox.ts => sandbox-snapshot.ts} (71%) diff --git a/README.md b/README.md index 1d92d1c..9626fe6 100644 --- a/README.md +++ b/README.md @@ -146,12 +146,12 @@ const sandbox = await Sandbox.create({ `Sandbox` and `CommandFinished` support serialization with the [Workflow DevKit](https://vercel.com/docs/workflow). When a sandbox instance -crosses a step boundary the SDK serializes only the sandbox ID, then -rehydrates a fresh instance on the other side by calling `Sandbox.get`. +crosses a step boundary the SDK serializes sandbox metadata and routes, then +rehydrates synchronously from that snapshot. Because the workflow runtime deserializes in a new execution context, credentials are not carried over. Call `setSandboxCredentials` in the module -scope so that deserialized instances can reconnect to the API: +scope so deserialized instances can make API calls when needed: ```ts import { Sandbox, setSandboxCredentials } from "@vercel/sandbox"; diff --git a/packages/vercel-sandbox/README.md b/packages/vercel-sandbox/README.md index 93d33bf..70831e8 100644 --- a/packages/vercel-sandbox/README.md +++ b/packages/vercel-sandbox/README.md @@ -146,12 +146,12 @@ const sandbox = await Sandbox.create({ `Sandbox` and `CommandFinished` support serialization with the [Workflow DevKit](https://vercel.com/docs/workflow). When a sandbox instance -crosses a step boundary the SDK serializes only the sandbox ID, then -rehydrates a fresh instance on the other side by calling `Sandbox.get`. +crosses a step boundary the SDK serializes sandbox metadata and routes, then +rehydrates synchronously from that snapshot. Because the workflow runtime deserializes in a new execution context, credentials are not carried over. Call `setSandboxCredentials` in the module -scope so that deserialized instances can reconnect to the API: +scope so deserialized instances can make API calls when needed: ```ts import { Sandbox, setSandboxCredentials } from "@vercel/sandbox"; diff --git a/packages/vercel-sandbox/src/sandbox.serialize.test.ts b/packages/vercel-sandbox/src/sandbox.serialize.test.ts index 3ef0550..aa00ac9 100644 --- a/packages/vercel-sandbox/src/sandbox.serialize.test.ts +++ b/packages/vercel-sandbox/src/sandbox.serialize.test.ts @@ -1,25 +1,14 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { - WORKFLOW_SERIALIZE, - WORKFLOW_DESERIALIZE, -} from "@workflow/serde"; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; import { registerSerializationClass } from "@workflow/core/class-serialization"; import { dehydrateStepReturnValue, hydrateStepReturnValue, } from "@workflow/core/serialization"; -import { Sandbox, SerializedSandbox, setSandboxCredentials } from "./sandbox"; +import { Sandbox, type SerializedSandbox } from "./sandbox"; import type { SandboxMetaData, SandboxRouteData } from "./api-client"; import { APIClient } from "./api-client"; - -// Mock the getCredentials function -vi.mock("./utils/get-credentials", () => ({ - getCredentials: vi.fn().mockResolvedValue({ - teamId: "team_test", - token: "test_token", - projectId: "project_test", - }), -})); +import { toSandboxSnapshot } from "./utils/sandbox-snapshot"; describe("Sandbox serialization", () => { const mockMetadata: SandboxMetaData = { @@ -35,6 +24,7 @@ describe("Sandbox serialization", () => { createdAt: 1700000000000, cwd: "/vercel/sandbox", updatedAt: 1700000002000, + networkPolicy: { mode: "allow-all" }, }; const mockRoutes: SandboxRouteData[] = [ @@ -53,216 +43,182 @@ describe("Sandbox serialization", () => { return new Sandbox({ client, - sandbox: metadata, + sandbox: toSandboxSnapshot(metadata), routes, }); }; - describe("WORKFLOW_SERIALIZE", () => { - it("serializes to just the sandbox ID", () => { - const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + const serializeSandbox = (sandbox: Sandbox): SerializedSandbox => { + return Sandbox[WORKFLOW_SERIALIZE](sandbox); + }; - expect(serialized).toEqual({ sandboxId: "sbx_test123" }); - }); + const deserializeSandbox = (data: SerializedSandbox): Sandbox => { + return Sandbox[WORKFLOW_DESERIALIZE](data); + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); - it("preserves the sandbox ID", () => { + describe("WORKFLOW_SERIALIZE", () => { + it("serializes sandbox snapshot data", () => { const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + const serialized = serializeSandbox(sandbox); - expect(serialized.sandboxId).toBe(sandbox.sandboxId); + expect(serialized.metadata.id).toBe("sbx_test123"); + expect(serialized.routes).toEqual(mockRoutes); + expect(serialized.metadata.networkPolicy).toBe("allow-all"); }); - it("returns a plain object that can be JSON serialized", () => { + it("returns plain JSON-serializable data", () => { const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + const serialized = serializeSandbox(sandbox); const jsonString = JSON.stringify(serialized); const parsed = JSON.parse(jsonString); - expect(parsed.sandboxId).toBe("sbx_test123"); + expect(parsed.metadata.id).toBe("sbx_test123"); + expect(parsed.routes).toEqual(mockRoutes); }); it("does not include the API client or credentials", () => { const sandbox = createMockSandbox(); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); + const serialized = serializeSandbox(sandbox); expect(serialized).not.toHaveProperty("client"); - expect(serialized).not.toHaveProperty("metadata"); - expect(serialized).not.toHaveProperty("routes"); expect(JSON.stringify(serialized)).not.toContain("token"); }); - - it("handles special characters in sandbox ID", () => { - const metadataWithSpecialId = { ...mockMetadata, id: "sbx_test-123_abc" }; - const sandbox = createMockSandbox(metadataWithSpecialId); - const serialized = Sandbox[WORKFLOW_SERIALIZE](sandbox); - - expect(serialized.sandboxId).toBe("sbx_test-123_abc"); - }); }); describe("WORKFLOW_DESERIALIZE", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("calls Sandbox.get with the serialized sandbox ID", async () => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - - const mockSandbox = createMockSandbox(); - const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(mockSandbox); - - const serializedData: SerializedSandbox = { sandboxId: "sbx_test123" }; - const result = await Sandbox[WORKFLOW_DESERIALIZE](serializedData); - - expect(getSpy).toHaveBeenCalledWith( - expect.objectContaining({ sandboxId: "sbx_test123" }), - ); - expect(result).toBe(mockSandbox); - }); - - it("returns a promise", () => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - - vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); - - const result = Sandbox[WORKFLOW_DESERIALIZE]({ sandboxId: "sbx_test123" }); - - expect(result).toBeInstanceOf(Promise); - }); - - it("passes global credentials to Sandbox.get", async () => { - setSandboxCredentials({ token: "my_token", teamId: "my_team" }); - - const getSpy = vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); + it("returns synchronously", () => { + const sandbox = createMockSandbox(); + const serialized = serializeSandbox(sandbox); - await Sandbox[WORKFLOW_DESERIALIZE]({ sandboxId: "sbx_test123" }); + const result = deserializeSandbox(serialized); - expect(getSpy).toHaveBeenCalledWith( - expect.objectContaining({ - sandboxId: "sbx_test123", - token: "my_token", - teamId: "my_team", - }), - ); + expect(result).toBeInstanceOf(Sandbox); + expect(result).not.toBeInstanceOf(Promise); }); - it("returns a fully functional Sandbox instance", async () => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - - const mockSandbox = createMockSandbox(); - vi.spyOn(Sandbox, "get").mockResolvedValue(mockSandbox); + it("reconstructs a fully usable snapshot-backed instance", () => { + const sandbox = createMockSandbox(); + const serialized = serializeSandbox(sandbox); - const result = await Sandbox[WORKFLOW_DESERIALIZE]({ sandboxId: "sbx_test123" }); + const result = deserializeSandbox(serialized); - expect(result).toBeInstanceOf(Sandbox); expect(result.sandboxId).toBe("sbx_test123"); expect(result.status).toBe("running"); expect(result.routes).toEqual(mockRoutes); - }); - }); - - describe("roundtrip serialization", () => { - afterEach(() => { - vi.restoreAllMocks(); + expect(result.networkPolicy).toBe("allow-all"); + expect(result.domain(3000)).toBe("https://test-3000.vercel.run"); }); - it("preserves sandboxId through roundtrip", async () => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - - const originalSandbox = createMockSandbox(); - vi.spyOn(Sandbox, "get").mockResolvedValue(originalSandbox); + it("does not require global credentials just to deserialize and read metadata", async () => { + vi.resetModules(); + const { Sandbox: FreshSandbox } = await import("./sandbox"); - const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); - expect(serialized.sandboxId).toBe("sbx_test123"); + const serializedData: SerializedSandbox = { + metadata: { + id: "sbx_test123", + memory: 2048, + vcpus: 1, + region: "us-east-1", + runtime: "node24", + timeout: 300000, + status: "running", + requestedAt: 1700000000000, + startedAt: 1700000001000, + createdAt: 1700000000000, + cwd: "/vercel/sandbox", + updatedAt: 1700000002000, + networkPolicy: "allow-all", + }, + routes: mockRoutes, + }; + + const deserialized = FreshSandbox[WORKFLOW_DESERIALIZE]( + serializedData, + ) as Sandbox; - const deserialized = await Sandbox[WORKFLOW_DESERIALIZE](serialized); expect(deserialized.sandboxId).toBe("sbx_test123"); + expect(deserialized.status).toBe("running"); + expect(deserialized.routes).toEqual(mockRoutes); }); - it("serialized data can be stored and retrieved via JSON", async () => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - - const originalSandbox = createMockSandbox(); - vi.spyOn(Sandbox, "get").mockResolvedValue(originalSandbox); - - const serialized = Sandbox[WORKFLOW_SERIALIZE](originalSandbox); - const storedJson = JSON.stringify(serialized); - - const retrievedData: SerializedSandbox = JSON.parse(storedJson); - const deserialized = await Sandbox[WORKFLOW_DESERIALIZE](retrievedData); - - expect(deserialized.sandboxId).toBe(originalSandbox.sandboxId); - }); - }); - - describe("global credentials error", () => { - afterEach(() => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - }); - - it("throws a helpful error when deserializing without global credentials", async () => { + it("still requires global credentials when API client is actually accessed", async () => { vi.resetModules(); const { Sandbox: FreshSandbox } = await import("./sandbox"); - const serializedData: SerializedSandbox = { sandboxId: "sbx_test123" }; - - expect(() => FreshSandbox[WORKFLOW_DESERIALIZE](serializedData)).toThrowError( + const serializedData: SerializedSandbox = { + metadata: { + id: "sbx_test123", + memory: 2048, + vcpus: 1, + region: "us-east-1", + runtime: "node24", + timeout: 300000, + status: "running", + requestedAt: 1700000000000, + startedAt: 1700000001000, + createdAt: 1700000000000, + cwd: "/vercel/sandbox", + updatedAt: 1700000002000, + networkPolicy: "allow-all", + }, + routes: mockRoutes, + }; + + const deserialized = FreshSandbox[WORKFLOW_DESERIALIZE]( + serializedData, + ) as Sandbox; + + expect(() => deserialized.client).toThrowError( /Global credentials have not been set/, ); - expect(() => FreshSandbox[WORKFLOW_DESERIALIZE](serializedData)).toThrowError( - /setSandboxCredentials/, - ); - }); - - it("does not throw when global credentials have been set", async () => { - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); - - vi.spyOn(Sandbox, "get").mockResolvedValue(createMockSandbox()); - - const serializedData: SerializedSandbox = { sandboxId: "sbx_test123" }; - - expect(() => Sandbox[WORKFLOW_DESERIALIZE](serializedData)).not.toThrow(); }); }); describe("workflow runtime integration", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("Sandbox survives a step boundary roundtrip", async () => { + it("survives a step boundary roundtrip", async () => { registerSerializationClass("Sandbox", Sandbox); - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const sandbox = createMockSandbox(); - vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); - - // Simulate step returning a Sandbox - const dehydrated = await dehydrateStepReturnValue(sandbox, "run_123", undefined); - expect(dehydrated).toBeInstanceOf(Uint8Array); - // Simulate workflow receiving the step result - const rehydrated = await hydrateStepReturnValue(dehydrated, "run_123", undefined); + const dehydrated = await dehydrateStepReturnValue( + sandbox, + "run_123", + undefined, + ); + const rehydrated = await hydrateStepReturnValue( + dehydrated, + "run_123", + undefined, + ); expect(rehydrated).toBeInstanceOf(Sandbox); - expect(rehydrated.sandboxId).toBe(sandbox.sandboxId); + expect(rehydrated.sandboxId).toBe("sbx_test123"); + expect(rehydrated.routes).toEqual(mockRoutes); }); - it("preserves sandbox properties through the runtime pipeline", async () => { + it("preserves converted metadata through runtime pipeline", async () => { registerSerializationClass("Sandbox", Sandbox); - setSandboxCredentials({ token: "test_token", teamId: "team_test" }); const sandbox = createMockSandbox(); - vi.spyOn(Sandbox, "get").mockResolvedValue(sandbox); - const dehydrated = await dehydrateStepReturnValue(sandbox, "run_456", undefined); - const rehydrated = await hydrateStepReturnValue(dehydrated, "run_456", undefined); + const dehydrated = await dehydrateStepReturnValue( + sandbox, + "run_456", + undefined, + ); + const rehydrated = await hydrateStepReturnValue( + dehydrated, + "run_456", + undefined, + ); - expect(rehydrated.sandboxId).toBe("sbx_test123"); expect(rehydrated.status).toBe("running"); - expect(rehydrated.routes).toEqual(mockRoutes); + expect(rehydrated.networkPolicy).toBe("allow-all"); }); }); }); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 3f31fd1..1f7cdd0 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -21,7 +21,10 @@ import { type NetworkPolicyRule, type NetworkTransformer, } from "./network-policy"; -import { convertSandbox, type ConvertedSandbox } from "./utils/convert-sandbox"; +import { + toSandboxSnapshot, + type SandboxSnapshot, +} from "./utils/sandbox-snapshot"; import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; @@ -124,7 +127,8 @@ interface GetSandboxParams { * Serialized representation of a Sandbox for @workflow/serde. */ export interface SerializedSandbox { - sandboxId: string; + metadata: SandboxSnapshot; + routes: SandboxRouteData[]; } /** @inline */ @@ -260,14 +264,16 @@ export class Sandbox { /** * The amount of network data used by the sandbox. Only reported once the VM is stopped. */ - public get networkTransfer(): {ingress: number, egress: number} | undefined { + public get networkTransfer(): + | { ingress: number; egress: number } + | undefined { return this.sandbox.networkTransfer; } /** * Internal metadata about this sandbox. */ - private sandbox: ConvertedSandbox; + private sandbox: SandboxSnapshot; /** * Set global credentials for Sandbox and Command instances. @@ -319,26 +325,29 @@ export class Sandbox { * Serialize a Sandbox instance to plain data for @workflow/serde. * * @param instance - The Sandbox instance to serialize - * @returns A plain object containing the sandbox ID + * @returns A plain object containing sandbox metadata and routes */ static [WORKFLOW_SERIALIZE](instance: Sandbox): SerializedSandbox { return { - sandboxId: instance.sandboxId, + metadata: instance.sandbox, + routes: instance.routes, }; } /** - * Deserialize a Sandbox by fetching its current state from the API. + * Deserialize a Sandbox from serialized snapshot data. * - * Requires global credentials to be set via {@link Sandbox.setCredentials} - * or {@link setSandboxCredentials} before deserialization. + * The deserialized instance uses the serialized metadata synchronously and + * lazily creates an API client only when methods perform API requests. * * @param data - The serialized sandbox data - * @returns A promise resolving to a fresh Sandbox instance + * @returns The reconstructed Sandbox instance */ - static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Promise { - const credentials = getSandboxCredentials(); - return Sandbox.get({ sandboxId: data.sandboxId, ...credentials }); + static [WORKFLOW_DESERIALIZE](data: SerializedSandbox): Sandbox { + return new Sandbox({ + sandbox: data.metadata, + routes: data.routes, + }); } /** @@ -382,7 +391,7 @@ export class Sandbox { return new DisposableSandbox({ client, - sandbox: sandbox.json.sandbox, + sandbox: toSandboxSnapshot(sandbox.json.sandbox), routes: sandbox.json.routes, }); } @@ -413,7 +422,7 @@ export class Sandbox { return new Sandbox({ client, - sandbox: sandbox.json.sandbox, + sandbox: toSandboxSnapshot(sandbox.json.sandbox), routes: sandbox.json.routes, }); } @@ -423,7 +432,7 @@ export class Sandbox { * * @param params.client - Optional API client. If not provided, will be lazily created using global credentials. * @param params.routes - Port-to-subdomain mappings for exposed ports - * @param params.sandbox - Sandbox metadata + * @param params.sandbox - Sandbox snapshot metadata */ constructor({ client, @@ -432,11 +441,11 @@ export class Sandbox { }: { client?: APIClient; routes: SandboxRouteData[]; - sandbox: SandboxMetaData; + sandbox: SandboxSnapshot; }) { this._client = client ?? null; this.routes = routes; - this.sandbox = convertSandbox(sandbox); + this.sandbox = sandbox; } /** @@ -735,13 +744,16 @@ export class Sandbox { * @param opts.blocking - If true, poll until the sandbox has fully stopped and return the final state. * @returns The sandbox metadata at the time the stop was acknowledged, or after fully stopped if `blocking` is true. */ - async stop(opts?: { signal?: AbortSignal; blocking?: boolean }): Promise { + async stop(opts?: { + signal?: AbortSignal; + blocking?: boolean; + }): Promise { const response = await this.client.stopSandbox({ sandboxId: this.sandbox.id, signal: opts?.signal, blocking: opts?.blocking, }); - this.sandbox = convertSandbox(response.json.sandbox); + this.sandbox = toSandboxSnapshot(response.json.sandbox); return this.sandbox; } @@ -787,7 +799,7 @@ export class Sandbox { }); // Update the internal sandbox metadata with the new timeout value - this.sandbox = convertSandbox(response.json.sandbox); + this.sandbox = toSandboxSnapshot(response.json.sandbox); return this.sandbox.networkPolicy!; } @@ -818,7 +830,7 @@ export class Sandbox { }); // Update the internal sandbox metadata with the new timeout value - this.sandbox = convertSandbox(response.json.sandbox); + this.sandbox = toSandboxSnapshot(response.json.sandbox); } /** @@ -842,7 +854,7 @@ export class Sandbox { signal: opts?.signal, }); - this.sandbox = convertSandbox(response.json.sandbox); + this.sandbox = toSandboxSnapshot(response.json.sandbox); return new Snapshot({ client: this.client, diff --git a/packages/vercel-sandbox/src/utils/convert-sandbox.ts b/packages/vercel-sandbox/src/utils/sandbox-snapshot.ts similarity index 71% rename from packages/vercel-sandbox/src/utils/convert-sandbox.ts rename to packages/vercel-sandbox/src/utils/sandbox-snapshot.ts index 0996334..a438905 100644 --- a/packages/vercel-sandbox/src/utils/convert-sandbox.ts +++ b/packages/vercel-sandbox/src/utils/sandbox-snapshot.ts @@ -2,11 +2,11 @@ import type { SandboxMetaData } from "../api-client"; import type { NetworkPolicy } from "../network-policy"; import { fromAPINetworkPolicy } from "./network-policy"; -export type ConvertedSandbox = Omit & { +export type SandboxSnapshot = Omit & { networkPolicy?: NetworkPolicy; }; -export function convertSandbox(sandbox: SandboxMetaData): ConvertedSandbox { +export function toSandboxSnapshot(sandbox: SandboxMetaData): SandboxSnapshot { const { networkPolicy, ...rest } = sandbox; return { ...rest, From 017ca9300faa4afab749a9e146f481932e762b64 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Mon, 2 Mar 2026 12:40:27 -0500 Subject: [PATCH 9/9] test: remove unsafe cast in command serde test --- .../src/command.serialize.test.ts | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/vercel-sandbox/src/command.serialize.test.ts b/packages/vercel-sandbox/src/command.serialize.test.ts index 138f256..865770e 100644 --- a/packages/vercel-sandbox/src/command.serialize.test.ts +++ b/packages/vercel-sandbox/src/command.serialize.test.ts @@ -1,15 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { - WORKFLOW_SERIALIZE, - WORKFLOW_DESERIALIZE, -} from "@workflow/serde"; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; import { registerSerializationClass } from "@workflow/core/class-serialization"; import { dehydrateStepReturnValue, hydrateStepReturnValue, } from "@workflow/core/serialization"; import { - Command, CommandFinished, SerializedCommandFinished, CommandOutput, @@ -112,8 +108,8 @@ describe("CommandFinished serialization", () => { const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); - expect(serialized.output.stdout).toBe("Custom stdout\n"); - expect(serialized.output.stderr).toBe("Custom stderr\n"); + expect(serialized.output?.stdout).toBe("Custom stdout\n"); + expect(serialized.output?.stderr).toBe("Custom stderr\n"); }); it("returns a plain object that can be JSON serialized", () => { @@ -157,7 +153,8 @@ describe("CommandFinished serialization", () => { output: mockOutput, }; - const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const commandFinished = + CommandFinished[WORKFLOW_DESERIALIZE](serializedData); expect(commandFinished).toBeInstanceOf(CommandFinished); expect(commandFinished.exitCode).toBe(0); @@ -186,7 +183,8 @@ describe("CommandFinished serialization", () => { output: mockOutput, }; - const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const commandFinished = + CommandFinished[WORKFLOW_DESERIALIZE](serializedData); expect(commandFinished.exitCode).toBe(127); }); @@ -199,7 +197,8 @@ describe("CommandFinished serialization", () => { output: mockOutput, }; - const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const commandFinished = + CommandFinished[WORKFLOW_DESERIALIZE](serializedData); expect(commandFinished.cmdId).toBe(mockCommandData.id); expect(commandFinished.cwd).toBe(mockCommandData.cwd); @@ -214,7 +213,8 @@ describe("CommandFinished serialization", () => { output: mockOutput, }; - const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const commandFinished = + CommandFinished[WORKFLOW_DESERIALIZE](serializedData); expect(await commandFinished.stdout()).toBe(mockOutput.stdout); expect(await commandFinished.stderr()).toBe(mockOutput.stderr); @@ -228,11 +228,12 @@ describe("CommandFinished serialization", () => { output: mockOutput, }; - const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const commandFinished = + CommandFinished[WORKFLOW_DESERIALIZE](serializedData); // Client is lazily created - internal _client should be null initially // (accessing .client would create one using OIDC by default) - expect((commandFinished as unknown as { _client: unknown })._client).toBeNull(); + expect(Reflect.get(commandFinished, "_client")).toBeNull(); }); }); @@ -340,13 +341,13 @@ describe("CommandFinished serialization", () => { const serialized = CommandFinished[WORKFLOW_SERIALIZE](commandFinished); - expect(serialized.output.stdout.length).toBe(10000); - expect(serialized.output.stderr.length).toBe(10000); + expect(serialized.output?.stdout.length).toBe(10000); + expect(serialized.output?.stderr.length).toBe(10000); }); it("handles output with special characters", async () => { const specialOutput: CommandOutput = { - stdout: "Hello\nWorld\t\"quoted\"\n", + stdout: 'Hello\nWorld\t"quoted"\n', stderr: "Error: 日本語\n", }; const commandFinished = createMockCommandFinished( @@ -418,7 +419,8 @@ describe("CommandFinished serialization", () => { output: mockOutput, }; - const commandFinished = CommandFinished[WORKFLOW_DESERIALIZE](serializedData); + const commandFinished = + CommandFinished[WORKFLOW_DESERIALIZE](serializedData); const waited = await commandFinished.wait(); expect(waited).toBe(commandFinished); @@ -441,11 +443,19 @@ describe("CommandFinished serialization", () => { ); // Simulate step returning a CommandFinished - const dehydrated = await dehydrateStepReturnValue(commandFinished, "run_123", undefined); + const dehydrated = await dehydrateStepReturnValue( + commandFinished, + "run_123", + undefined, + ); expect(dehydrated).toBeInstanceOf(Uint8Array); // Simulate workflow receiving the step result - const rehydrated = await hydrateStepReturnValue(dehydrated, "run_123", undefined); + const rehydrated = await hydrateStepReturnValue( + dehydrated, + "run_123", + undefined, + ); expect(rehydrated).toBeInstanceOf(CommandFinished); expect(rehydrated.exitCode).toBe(0); @@ -462,8 +472,16 @@ describe("CommandFinished serialization", () => { mockOutput, ); - const dehydrated = await dehydrateStepReturnValue(commandFinished, "run_456", undefined); - const rehydrated = await hydrateStepReturnValue(dehydrated, "run_456", undefined); + const dehydrated = await dehydrateStepReturnValue( + commandFinished, + "run_456", + undefined, + ); + const rehydrated = await hydrateStepReturnValue( + dehydrated, + "run_456", + undefined, + ); expect(rehydrated.exitCode).toBe(42); expect(await rehydrated.stdout()).toBe(mockOutput.stdout);