Skip to content

Commit 3fbabb9

Browse files
authored
fix(sandbox): add workflow serialization support for Snapshot class (#140)
## Summary - Adds `WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` static methods to the `Snapshot` class, fixing serialization errors when a `Snapshot` instance is returned from a workflow step. - Uses the same lazy-client pattern as `Sandbox` and `Command`: only plain `SnapshotMetadata` is serialized (credentials are excluded), and an API client is lazily created via `ensureClient()` when needed after deserialization. - Includes 10 tests covering serialize/deserialize roundtrips, credential exclusion, and full `dehydrate`/`hydrate` through the workflow runtime pipeline.
1 parent 42515e1 commit 3fbabb9

File tree

4 files changed

+255
-7
lines changed

4 files changed

+255
-7
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vercel/sandbox": patch
3+
---
4+
5+
Add workflow serialization support for the `Snapshot` class via `WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`, fixing serialization errors when a `Snapshot` instance is returned from a workflow step.

packages/vercel-sandbox/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
} from "./sandbox.js";
77
export type { SerializedSandbox } from "./sandbox.js";
88
export { Snapshot } from "./snapshot.js";
9+
export type { SerializedSnapshot } from "./snapshot.js";
910
export { Command, CommandFinished } from "./command.js";
1011
export type {
1112
SerializedCommand,
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { registerSerializationClass } from "@workflow/core/class-serialization";
2+
import {
3+
dehydrateStepReturnValue,
4+
hydrateStepReturnValue,
5+
} from "@workflow/core/serialization";
6+
import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde";
7+
import { afterEach, describe, expect, it, vi } from "vitest";
8+
import type { SnapshotMetadata } from "./api-client";
9+
import { APIClient } from "./api-client";
10+
import { Snapshot, type SerializedSnapshot } from "./snapshot";
11+
12+
describe("Snapshot serialization", () => {
13+
const mockSnapshotMetadata: SnapshotMetadata = {
14+
id: "snap_test123",
15+
sourceSandboxId: "sbx_source456",
16+
region: "iad1",
17+
status: "created",
18+
sizeBytes: 253826392,
19+
expiresAt: 1775737021391,
20+
createdAt: 1775650621392,
21+
updatedAt: 1775650621392,
22+
};
23+
24+
const createMockSnapshot = (
25+
metadata: SnapshotMetadata = mockSnapshotMetadata,
26+
): Snapshot => {
27+
const client = new APIClient({
28+
teamId: "team_test",
29+
token: "test_token",
30+
});
31+
32+
return new Snapshot({
33+
client,
34+
snapshot: metadata,
35+
});
36+
};
37+
38+
const serializeSnapshot = (snapshot: Snapshot): SerializedSnapshot => {
39+
return Snapshot[WORKFLOW_SERIALIZE](snapshot);
40+
};
41+
42+
const deserializeSnapshot = (data: SerializedSnapshot): Snapshot => {
43+
return Snapshot[WORKFLOW_DESERIALIZE](data);
44+
};
45+
46+
afterEach(() => {
47+
vi.restoreAllMocks();
48+
});
49+
50+
describe("WORKFLOW_SERIALIZE", () => {
51+
it("serializes snapshot metadata", () => {
52+
const snapshot = createMockSnapshot();
53+
const serialized = serializeSnapshot(snapshot);
54+
55+
expect(serialized.snapshot.id).toBe("snap_test123");
56+
expect(serialized.snapshot.sourceSandboxId).toBe("sbx_source456");
57+
expect(serialized.snapshot.region).toBe("iad1");
58+
expect(serialized.snapshot.status).toBe("created");
59+
expect(serialized.snapshot.sizeBytes).toBe(253826392);
60+
});
61+
62+
it("returns plain JSON-serializable data", () => {
63+
const snapshot = createMockSnapshot();
64+
const serialized = serializeSnapshot(snapshot);
65+
66+
const jsonString = JSON.stringify(serialized);
67+
const parsed = JSON.parse(jsonString);
68+
69+
expect(parsed.snapshot.id).toBe("snap_test123");
70+
expect(parsed.snapshot.sourceSandboxId).toBe("sbx_source456");
71+
});
72+
73+
it("does not include the API client or credentials", () => {
74+
const snapshot = createMockSnapshot();
75+
const serialized = serializeSnapshot(snapshot);
76+
77+
expect(serialized).not.toHaveProperty("client");
78+
expect(serialized).not.toHaveProperty("_client");
79+
expect(JSON.stringify(serialized)).not.toContain("token");
80+
});
81+
});
82+
83+
describe("WORKFLOW_DESERIALIZE", () => {
84+
it("returns synchronously", () => {
85+
const snapshot = createMockSnapshot();
86+
const serialized = serializeSnapshot(snapshot);
87+
88+
const result = deserializeSnapshot(serialized);
89+
90+
expect(result).toBeInstanceOf(Snapshot);
91+
expect(result).not.toBeInstanceOf(Promise);
92+
});
93+
94+
it("reconstructs a fully usable metadata-backed instance", () => {
95+
const snapshot = createMockSnapshot();
96+
const serialized = serializeSnapshot(snapshot);
97+
98+
const result = deserializeSnapshot(serialized);
99+
100+
expect(result.snapshotId).toBe("snap_test123");
101+
expect(result.sourceSandboxId).toBe("sbx_source456");
102+
expect(result.status).toBe("created");
103+
expect(result.sizeBytes).toBe(253826392);
104+
expect(result.createdAt).toEqual(new Date(1775650621392));
105+
expect(result.expiresAt).toEqual(new Date(1775737021391));
106+
});
107+
108+
it("does not require global credentials just to deserialize and read metadata", async () => {
109+
vi.resetModules();
110+
const { Snapshot: FreshSnapshot } = await import("./snapshot");
111+
112+
const serializedData: SerializedSnapshot = {
113+
snapshot: mockSnapshotMetadata,
114+
};
115+
116+
const deserialized = FreshSnapshot[WORKFLOW_DESERIALIZE](
117+
serializedData,
118+
) as Snapshot;
119+
120+
expect(deserialized.snapshotId).toBe("snap_test123");
121+
expect(deserialized.sourceSandboxId).toBe("sbx_source456");
122+
expect(deserialized.status).toBe("created");
123+
});
124+
125+
it("deserialized instance has no client until ensureClient() is called", async () => {
126+
vi.resetModules();
127+
const { Snapshot: FreshSnapshot } = await import("./snapshot");
128+
129+
const serializedData: SerializedSnapshot = {
130+
snapshot: mockSnapshotMetadata,
131+
};
132+
133+
const deserialized = FreshSnapshot[WORKFLOW_DESERIALIZE](
134+
serializedData,
135+
) as Snapshot;
136+
137+
expect((deserialized as any)._client).toBeNull();
138+
});
139+
140+
it("handles snapshot without expiresAt", () => {
141+
const metadataWithoutExpiry: SnapshotMetadata = {
142+
...mockSnapshotMetadata,
143+
expiresAt: undefined,
144+
};
145+
146+
const snapshot = createMockSnapshot(metadataWithoutExpiry);
147+
const serialized = serializeSnapshot(snapshot);
148+
const result = deserializeSnapshot(serialized);
149+
150+
expect(result.expiresAt).toBeUndefined();
151+
expect(result.snapshotId).toBe("snap_test123");
152+
});
153+
});
154+
155+
describe("workflow runtime integration", () => {
156+
it("survives a step boundary roundtrip", async () => {
157+
registerSerializationClass("Snapshot", Snapshot);
158+
159+
const snapshot = createMockSnapshot();
160+
161+
const dehydrated = await dehydrateStepReturnValue(
162+
snapshot,
163+
"run_123",
164+
undefined,
165+
);
166+
const rehydrated = await hydrateStepReturnValue(
167+
dehydrated,
168+
"run_123",
169+
undefined,
170+
);
171+
172+
expect(rehydrated).toBeInstanceOf(Snapshot);
173+
expect(rehydrated.snapshotId).toBe("snap_test123");
174+
expect(rehydrated.sourceSandboxId).toBe("sbx_source456");
175+
});
176+
177+
it("preserves all metadata through runtime pipeline", async () => {
178+
registerSerializationClass("Snapshot", Snapshot);
179+
180+
const snapshot = createMockSnapshot();
181+
182+
const dehydrated = await dehydrateStepReturnValue(
183+
snapshot,
184+
"run_456",
185+
undefined,
186+
);
187+
const rehydrated = await hydrateStepReturnValue(
188+
dehydrated,
189+
"run_456",
190+
undefined,
191+
);
192+
193+
expect(rehydrated.status).toBe("created");
194+
expect(rehydrated.sizeBytes).toBe(253826392);
195+
expect(rehydrated.createdAt).toEqual(new Date(1775650621392));
196+
});
197+
});
198+
});

packages/vercel-sandbox/src/snapshot.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde";
12
import type { WithFetchOptions } from "./api-client/api-client.js";
23
import type { SnapshotMetadata } from "./api-client/index.js";
34
import { APIClient } from "./api-client/index.js";
45
import { type Credentials, getCredentials } from "./utils/get-credentials.js";
56

7+
export interface SerializedSnapshot {
8+
snapshot: SnapshotMetadata;
9+
}
10+
611
/** @inline */
712
interface GetSnapshotParams {
813
/**
@@ -22,7 +27,24 @@ interface GetSnapshotParams {
2227
* @hideconstructor
2328
*/
2429
export class Snapshot {
25-
private readonly client: APIClient;
30+
private _client: APIClient | null = null;
31+
32+
/**
33+
* Lazily resolve credentials and construct an API client.
34+
* This is used in step contexts where the Snapshot was deserialized
35+
* without a client (e.g. when crossing workflow/step boundaries).
36+
* @internal
37+
*/
38+
private async ensureClient(): Promise<APIClient> {
39+
"use step";
40+
if (this._client) return this._client;
41+
const credentials = await getCredentials();
42+
this._client = new APIClient({
43+
teamId: credentials.teamId,
44+
token: credentials.token,
45+
});
46+
return this._client;
47+
}
2648

2749
/**
2850
* Unique ID of this snapshot.
@@ -76,19 +98,40 @@ export class Snapshot {
7698
private snapshot: SnapshotMetadata;
7799

78100
/**
79-
* Create a new Snapshot instance.
101+
* Serialize a Snapshot instance to plain data for @workflow/serde.
80102
*
81-
* @param client - API client used to communicate with the backend
82-
* @param snapshot - Snapshot metadata
103+
* @param instance - The Snapshot instance to serialize
104+
* @returns A plain object containing snapshot metadata
83105
*/
106+
static [WORKFLOW_SERIALIZE](instance: Snapshot): SerializedSnapshot {
107+
return {
108+
snapshot: instance.snapshot,
109+
};
110+
}
111+
112+
/**
113+
* Deserialize a Snapshot from serialized data.
114+
*
115+
* The deserialized instance uses the serialized metadata synchronously and
116+
* lazily creates an API client only when methods perform API requests.
117+
*
118+
* @param data - The serialized snapshot data
119+
* @returns The reconstructed Snapshot instance
120+
*/
121+
static [WORKFLOW_DESERIALIZE](data: SerializedSnapshot): Snapshot {
122+
return new Snapshot({
123+
snapshot: data.snapshot,
124+
});
125+
}
126+
84127
constructor({
85128
client,
86129
snapshot,
87130
}: {
88-
client: APIClient;
131+
client?: APIClient;
89132
snapshot: SnapshotMetadata;
90133
}) {
91-
this.client = client;
134+
this._client = client ?? null;
92135
this.snapshot = snapshot;
93136
}
94137

@@ -151,7 +194,8 @@ export class Snapshot {
151194
*/
152195
async delete(opts?: { signal?: AbortSignal }): Promise<void> {
153196
"use step";
154-
const response = await this.client!.deleteSnapshot({
197+
const client = await this.ensureClient();
198+
const response = await client.deleteSnapshot({
155199
snapshotId: this.snapshot.id,
156200
signal: opts?.signal,
157201
});

0 commit comments

Comments
 (0)