From a938bc000f2ec84c76edfd0b0c17496179658693 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 14 May 2026 14:49:02 +0200 Subject: [PATCH 1/5] feat(vercel-sandbox,sandbox): support snapshot tree pagination --- packages/sandbox/src/client.ts | 4 +- packages/sandbox/src/commands/snapshots.ts | 171 ++++++- .../sandbox/src/util/snapshot-tree.test.ts | 463 ++++++++++++++++++ packages/sandbox/src/util/snapshot-tree.ts | 163 ++++++ .../src/api-client/api-client.test.ts | 109 +++++ .../src/api-client/api-client.ts | 35 ++ .../src/api-client/validators.ts | 16 + packages/vercel-sandbox/src/index.ts | 1 + packages/vercel-sandbox/src/snapshot.ts | 46 ++ 9 files changed, 1006 insertions(+), 2 deletions(-) create mode 100644 packages/sandbox/src/util/snapshot-tree.test.ts create mode 100644 packages/sandbox/src/util/snapshot-tree.ts diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index 17205ec0..a8a53a11 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -28,7 +28,7 @@ export const sandboxClient: Pick = { export const snapshotClient: Pick< typeof Snapshot, - "get" | "list" | "fromSandbox" + "get" | "list" | "fromSandbox" | "tree" > = { list: (params) => withErrorHandling(() => @@ -39,6 +39,8 @@ export const snapshotClient: Pick< withErrorHandling( () => Snapshot.fromSandbox(name, { fetch: fetchWithUserAgent, ...opts }), ), + tree: (params) => + withErrorHandling(Snapshot.tree({ fetch: fetchWithUserAgent, ...params })), }; const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => { diff --git a/packages/sandbox/src/commands/snapshots.ts b/packages/sandbox/src/commands/snapshots.ts index c6a45ad2..981b9b32 100644 --- a/packages/sandbox/src/commands/snapshots.ts +++ b/packages/sandbox/src/commands/snapshots.ts @@ -6,9 +6,10 @@ import ora from "ora"; import { scope } from "../args/scope"; import { sandboxName } from "../args/sandbox-name"; import { snapshotId } from "../args/snapshot-id"; -import { snapshotClient } from "../client"; +import { sandboxClient, snapshotClient } from "../client"; import { acquireRelease } from "../util/disposables"; import { formatBytes, formatNextCursorHint, table, timeAgo } from "../util/output"; +import { renderSnapshotTree } from "../util/snapshot-tree"; const list = cmd.command({ name: "list", @@ -172,12 +173,180 @@ const remove = cmd.command({ }, }); +const tree = cmd.command({ + name: "tree", + description: "Show the snapshot ancestry tree for a sandbox.", + args: { + scope, + sandboxName: cmd.positional({ + type: sandboxName, + description: "Sandbox name", + }), + limit: cmd.option({ + long: "limit", + description: + "Maximum number of snapshots per page and direction (1–10, default 10).", + type: cmd.optional(cmd.number), + }), + cursor: cmd.option({ + long: "cursor", + description: + "Pagination cursor from a previous 'More ancestors' or 'More descendants' hint.", + type: cmd.optional(cmd.string), + }), + direction: cmd.option({ + long: "direction", + description: + "Pagination direction (default desc). 'desc' = ancestors, 'asc' = descendants. Only used with --cursor.", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), + }, + async handler({ + scope: { token, team, project }, + sandboxName: name, + limit, + cursor, + direction, + }) { + if (limit !== undefined && (limit < 1 || limit > 10)) { + console.error( + chalk.red("Error: --limit must be between 1 and 10."), + ); + process.exitCode = 1; + return; + } + + const pageLimit = limit ?? 10; + + // Paginated single-direction branch. + if (cursor) { + const sortOrder = direction ?? "desc"; + const page = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching snapshot tree...").start(), + (s) => s.stop(), + ); + return snapshotClient.tree({ + snapshotId: cursor, + sortOrder, + limit: pageLimit, + token, + teamId: team, + projectId: project, + }); + })(); + + const ancestors = + sortOrder === "desc" + ? page + : { snapshots: [], pagination: { count: 0, next: null } }; + const descendants = + sortOrder === "asc" + ? page + : { snapshots: [], pagination: { count: 0, next: null } }; + + console.log( + renderSnapshotTree({ + currentSnapshotId: "", + hideCurrent: true, + ancestors, + descendants, + }), + ); + + if (page.pagination.next !== null) { + console.log(formatNextCursorHint(page.pagination.next)); + } + return; + } + + // Default: bidirectional view anchored on the sandbox's current snapshot. + const result = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching snapshot tree...").start(), + (s) => s.stop(), + ); + + const sandbox = await sandboxClient.get({ + name, + token, + teamId: team, + projectId: project, + }); + + const currentSnapshotId = sandbox.currentSnapshotId; + if (!currentSnapshotId) { + return null; + } + + const [currentSnap, ancestors, descendants] = await Promise.all([ + snapshotClient.get({ + snapshotId: currentSnapshotId, + token, + teamId: team, + projectId: project, + }), + snapshotClient.tree({ + snapshotId: currentSnapshotId, + sortOrder: "desc", + limit: pageLimit, + token, + teamId: team, + projectId: project, + }), + snapshotClient.tree({ + snapshotId: currentSnapshotId, + sortOrder: "asc", + limit: pageLimit, + token, + teamId: team, + projectId: project, + }), + ]); + + return { + currentSnap, + currentSnapshotId, + ancestors, + descendants, + }; + })(); + + if (!result) { + console.log(chalk.yellow("No snapshots found for this sandbox.")); + return; + } + + console.log( + renderSnapshotTree({ + currentSnapshotId: result.currentSnapshotId, + currentSnapshotExpiresAt: result.currentSnap.expiresAt?.getTime(), + ancestors: result.ancestors, + descendants: result.descendants, + }), + ); + + const limitArg = limit !== undefined ? ` --limit ${limit}` : ""; + if (result.ancestors.pagination.next !== null) { + console.log( + `\nMore ancestors: sandbox snapshots tree ${name} --direction desc${limitArg} --cursor ${result.ancestors.pagination.next}`, + ); + } + if (result.descendants.pagination.next !== null) { + console.log( + `More descendants: sandbox snapshots tree ${name} --direction asc${limitArg} --cursor ${result.descendants.pagination.next}`, + ); + } + }, +}); + export const snapshots = subcommands({ name: "snapshots", description: "Manage sandbox snapshots", cmds: { list, get, + tree, delete: remove, }, }); diff --git a/packages/sandbox/src/util/snapshot-tree.test.ts b/packages/sandbox/src/util/snapshot-tree.test.ts new file mode 100644 index 00000000..ff23107e --- /dev/null +++ b/packages/sandbox/src/util/snapshot-tree.test.ts @@ -0,0 +1,463 @@ +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { + renderSnapshotTree, + type RenderSnapshotTreeParams, +} from "./snapshot-tree"; + +// Strip ANSI escape codes so assertions don't depend on chalk's color level. +const ANSI_REGEX = /\x1b\[[0-9;]*m/g; +const strip = (s: string) => s.replace(ANSI_REGEX, ""); + +interface SnapshotData { + id: string; + sourceSessionId: string; + expiresAt?: number; + parentId?: string; +} + +function makeSnapshot(overrides: Partial = {}): SnapshotData { + return { + id: "snap_default", + sourceSessionId: "sbx_default", + ...overrides, + }; +} + +function makeTreeNode( + snapshot: SnapshotData, + siblings: SnapshotData[] = [], + count?: string, +) { + return { + snapshot, + siblings, + count: count ?? String(siblings.length + 1), + }; +} + +function emptyTree() { + return { + snapshots: [] as ReturnType[], + pagination: { count: 0, next: null }, + }; +} + +describe("renderSnapshotTree", () => { + const NOW = 1_700_000_000_000; + + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + test("renders only the current snapshot with a root marker when there are no ancestors or descendants", () => { + const output = renderSnapshotTree({ + currentSnapshotId: "snap_current", + currentSnapshotExpiresAt: NOW + 7 * 24 * 60 * 60 * 1000, + ancestors: emptyTree(), + descendants: emptyTree(), + }); + + const plain = strip(output); + expect(plain).toContain("snap_current"); + expect(plain).toContain("◂ current"); + expect(plain).toContain("expires: in 7 days"); + expect(plain).toContain("(root)"); + }); + + test("renders current snapshot with ancestors walking upward", () => { + const current = makeSnapshot({ + id: "snap_current", + parentId: "snap_parent", + }); + const parent = makeSnapshot({ id: "snap_parent", parentId: "snap_root" }); + const root = makeSnapshot({ id: "snap_root" }); + + const params: RenderSnapshotTreeParams = { + currentSnapshotId: "snap_current", + currentSnapshotExpiresAt: NOW + 2 * 60 * 60 * 1000, + ancestors: { + snapshots: [makeTreeNode(parent), makeTreeNode(root)], + pagination: { count: 2, next: null }, + }, + descendants: emptyTree(), + }; + + const plain = strip(renderSnapshotTree(params)); + const currentIdx = plain.indexOf("snap_current"); + const parentIdx = plain.indexOf("snap_parent"); + const rootIdx = plain.indexOf("snap_root"); + + // current is above parent, parent is above root + expect(currentIdx).toBeGreaterThan(-1); + expect(parentIdx).toBeGreaterThan(currentIdx); + expect(rootIdx).toBeGreaterThan(parentIdx); + // root marker rendered after we reach the end with a root-less ancestor + expect(plain).toContain("(root)"); + }); + + test("renders descendants with newest at top (reversed from asc order)", () => { + // API returns descendants in ascending order (child, grandchild, …). We + // expect the rendered output to have the newest (last) at the top. + const child = makeSnapshot({ id: "snap_child" }); + const grandchild = makeSnapshot({ id: "snap_grandchild" }); + + const params: RenderSnapshotTreeParams = { + currentSnapshotId: "snap_current", + ancestors: emptyTree(), + descendants: { + snapshots: [makeTreeNode(child), makeTreeNode(grandchild)], + pagination: { count: 2, next: null }, + }, + }; + + const plain = strip(renderSnapshotTree(params)); + const grandchildIdx = plain.indexOf("snap_grandchild"); + const childIdx = plain.indexOf("snap_child"); + const currentIdx = plain.indexOf("snap_current"); + + expect(grandchildIdx).toBeGreaterThan(-1); + expect(childIdx).toBeGreaterThan(grandchildIdx); + expect(currentIdx).toBeGreaterThan(childIdx); + }); + + test("marks only the current snapshot with the 'current' indicator", () => { + const parent = makeSnapshot({ id: "snap_parent" }); + const child = makeSnapshot({ id: "snap_child" }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(parent)], + pagination: { count: 1, next: null }, + }, + descendants: { + snapshots: [makeTreeNode(child)], + pagination: { count: 1, next: null }, + }, + }), + ); + + const currentMatches = plain.match(/◂ current/g) ?? []; + expect(currentMatches).toHaveLength(1); + + const currentLine = plain.split("\n").find((l) => l.includes("◂ current")); + expect(currentLine).toContain("snap_current"); + expect(currentLine).not.toContain("snap_parent"); + expect(currentLine).not.toContain("snap_child"); + }); + + test("does not duplicate the current snapshot when it appears in ancestors", () => { + const currentNode = makeTreeNode( + makeSnapshot({ id: "snap_current", expiresAt: NOW + 60 * 60 * 1000 }), + ); + const parentNode = makeTreeNode(makeSnapshot({ id: "snap_parent" })); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [currentNode, parentNode], + pagination: { count: 2, next: null }, + }, + descendants: emptyTree(), + }), + ); + + const occurrences = plain.match(/snap_current/g) ?? []; + expect(occurrences).toHaveLength(1); + expect(plain).toContain("snap_parent"); + }); + + test("does not duplicate the current snapshot when it appears in descendants", () => { + const currentNode = makeTreeNode(makeSnapshot({ id: "snap_current" })); + const childNode = makeTreeNode(makeSnapshot({ id: "snap_child" })); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: emptyTree(), + descendants: { + snapshots: [currentNode, childNode], + pagination: { count: 2, next: null }, + }, + }), + ); + + const occurrences = plain.match(/snap_current/g) ?? []; + expect(occurrences).toHaveLength(1); + expect(plain).toContain("snap_child"); + }); + + test("renders siblings below a node up to the max-show limit", () => { + const parent = makeSnapshot({ id: "snap_parent" }); + const siblings = [ + makeSnapshot({ id: "snap_s1", sourceSessionId: "sbx_s1" }), + makeSnapshot({ id: "snap_s2", sourceSessionId: "sbx_s2" }), + ]; + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(parent, siblings, "3")], + pagination: { count: 1, next: null }, + }, + descendants: emptyTree(), + }), + ); + + expect(plain).toContain("sbx_s1"); + expect(plain).toContain("sbx_s2"); + expect(plain).not.toContain("more sandboxes"); + }); + + test("renders '+N more sandboxes' when siblings exceed the max-show limit", () => { + const parent = makeSnapshot({ id: "snap_parent" }); + // 7 siblings total (count = 8, main + 7 siblings), max shown is 5 + const siblings = Array.from({ length: 7 }, (_, i) => + makeSnapshot({ id: `snap_s${i}`, sourceSessionId: `sbx_s${i}` }), + ); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(parent, siblings, "8")], + pagination: { count: 1, next: null }, + }, + descendants: emptyTree(), + }), + ); + + // First 5 sessions visible + for (let i = 0; i < 5; i++) { + expect(plain).toContain(`sbx_s${i}`); + } + // remaining = totalSiblings(7) - shown(5) = 2 + expect(plain).toContain("+2 more sandboxes"); + }); + + test("renders '+N+ more sandboxes' when the count is truncated (e.g. '10+')", () => { + const parent = makeSnapshot({ id: "snap_parent" }); + // Server returned "10+" with 9 siblings (CHILDREN_PER_NODE_LIMIT - 1) + const siblings = Array.from({ length: 9 }, (_, i) => + makeSnapshot({ id: `snap_s${i}`, sourceSessionId: `sbx_s${i}` }), + ); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(parent, siblings, "10+")], + pagination: { count: 1, next: null }, + }, + descendants: emptyTree(), + }), + ); + + // remaining = totalSiblings(9) - shown(5) = 4, with "+" suffix + expect(plain).toContain("+4+ more sandboxes"); + }); + + test("renders the root marker when ancestors have no parent and pagination is exhausted", () => { + const root = makeSnapshot({ id: "snap_root" /* no parentId */ }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(root)], + pagination: { count: 1, next: null }, + }, + descendants: emptyTree(), + }), + ); + + expect(plain).toContain("(root)"); + }); + + test("does not render the root marker when ancestor pagination has a next cursor", () => { + const ancestor = makeSnapshot({ + id: "snap_ancestor", + parentId: "snap_older", + }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(ancestor)], + pagination: { count: 1, next: "snap_ancestor" }, + }, + descendants: emptyTree(), + }), + ); + + expect(plain).not.toContain("(root)"); + }); + + test("does not render the root marker when the last ancestor still has a parentId", () => { + // Pagination reached the end but the final ancestor has a parent, meaning + // the chain continues outside this project (or was otherwise unreachable). + const ancestor = makeSnapshot({ + id: "snap_ancestor", + parentId: "snap_unreachable", + }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(ancestor)], + pagination: { count: 1, next: null }, + }, + descendants: emptyTree(), + }), + ); + + expect(plain).not.toContain("(root)"); + }); + + test("formats 'never' when no expiration is set on the current snapshot", () => { + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + currentSnapshotExpiresAt: undefined, + ancestors: emptyTree(), + descendants: emptyTree(), + }), + ); + + expect(plain).toContain("expires: never"); + }); + + test("formats 'expired' when the expiration is in the past", () => { + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + currentSnapshotExpiresAt: NOW - 60 * 1000, + ancestors: emptyTree(), + descendants: emptyTree(), + }), + ); + + expect(plain).toContain("expires: expired"); + }); + + test("formats durations in days / hours / minutes", () => { + const parent = makeSnapshot({ + id: "snap_parent_days", + expiresAt: NOW + 3 * 24 * 60 * 60 * 1000, + }); + const grandparent = makeSnapshot({ + id: "snap_grandparent_hours", + expiresAt: NOW + 5 * 60 * 60 * 1000, + }); + const greatGrandparent = makeSnapshot({ + id: "snap_ggp_minutes", + expiresAt: NOW + 30 * 60 * 1000, + }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + currentSnapshotExpiresAt: NOW + 24 * 60 * 60 * 1000, + ancestors: { + snapshots: [ + makeTreeNode(parent), + makeTreeNode(grandparent), + makeTreeNode(greatGrandparent), + ], + pagination: { count: 3, next: "snap_ggp_minutes" }, + }, + descendants: emptyTree(), + }), + ); + + expect(plain).toContain("expires: in 1 day"); + expect(plain).toContain("expires: in 3 days"); + expect(plain).toContain("expires: in 5 hours"); + expect(plain).toContain("expires: in 30 minutes"); + }); + + test("does not throw when the current snapshot is absent from both ancestor and descendant lists", () => { + expect(() => + renderSnapshotTree({ + currentSnapshotId: "snap_current", + currentSnapshotExpiresAt: NOW + 1000 * 60 * 60 * 24, + ancestors: emptyTree(), + descendants: emptyTree(), + }), + ).not.toThrow(); + }); + + test("suppresses the current-snapshot node when hideCurrent is true", () => { + const parent = makeSnapshot({ + id: "snap_parent", + parentId: "snap_grandparent", + }); + const grandparent = makeSnapshot({ id: "snap_grandparent" }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_anchor_already_shown", + hideCurrent: true, + ancestors: { + snapshots: [makeTreeNode(parent), makeTreeNode(grandparent)], + pagination: { count: 2, next: null }, + }, + descendants: emptyTree(), + }), + ); + + expect(plain).toContain("snap_parent"); + expect(plain).toContain("snap_grandparent"); + expect(plain).not.toContain("snap_anchor_already_shown"); + expect(plain).not.toContain("◂ current"); + + const idxParent = plain.indexOf("snap_parent"); + const idxGrandparent = plain.indexOf("snap_grandparent"); + expect(idxGrandparent).toBeGreaterThan(idxParent); + }); + + test("renders the full picture: descendants at top, current in middle, ancestors below", () => { + const child = makeSnapshot({ id: "snap_child" }); + const parent = makeSnapshot({ + id: "snap_parent", + parentId: "snap_root", + }); + const root = makeSnapshot({ id: "snap_root" }); + + const plain = strip( + renderSnapshotTree({ + currentSnapshotId: "snap_current", + ancestors: { + snapshots: [makeTreeNode(parent), makeTreeNode(root)], + pagination: { count: 2, next: null }, + }, + descendants: { + snapshots: [makeTreeNode(child)], + pagination: { count: 1, next: null }, + }, + }), + ); + + const idxChild = plain.indexOf("snap_child"); + const idxCurrent = plain.indexOf("snap_current"); + const idxParent = plain.indexOf("snap_parent"); + const idxRoot = plain.indexOf("snap_root"); + const idxRootMarker = plain.indexOf("(root)"); + + expect(idxChild).toBeGreaterThan(-1); + expect(idxCurrent).toBeGreaterThan(idxChild); + expect(idxParent).toBeGreaterThan(idxCurrent); + expect(idxRoot).toBeGreaterThan(idxParent); + expect(idxRootMarker).toBeGreaterThan(idxRoot); + }); +}); diff --git a/packages/sandbox/src/util/snapshot-tree.ts b/packages/sandbox/src/util/snapshot-tree.ts new file mode 100644 index 00000000..a5eb4615 --- /dev/null +++ b/packages/sandbox/src/util/snapshot-tree.ts @@ -0,0 +1,163 @@ +import chalk from "chalk"; +import { timeAgo } from "./output"; + +interface SnapshotData { + id: string; + sourceSessionId: string; + expiresAt?: number; + parentId?: string; +} + +interface TreeNode { + snapshot: SnapshotData; + siblings: SnapshotData[]; + count: string; +} + +interface TreeResponse { + snapshots: TreeNode[]; + pagination: { count: number; next: string | null }; +} + +export interface RenderSnapshotTreeParams { + currentSnapshotId: string; + currentSnapshotExpiresAt?: number; + ancestors: TreeResponse; + descendants: TreeResponse; + /** + * When true, suppress the "current" snapshot node. Used when rendering a + * single-direction paginated view, where the anchor was already shown on + * the previous page. + */ + hideCurrent?: boolean; +} + +function formatExpires(expiresAt: number | undefined): string { + if (expiresAt === undefined) { + return chalk.gray("never"); + } + + const ms = expiresAt - Date.now(); + if (ms <= 0) { + return chalk.red("expired"); + } + + const formatted = timeAgo(expiresAt); + return ms <= 60 * 60 * 1000 ? chalk.red(formatted) : chalk.green(formatted); +} + +function renderNode( + id: string, + expiresAt: number | undefined, + isCurrent: boolean, +): string { + const bullet = isCurrent ? chalk.magenta.bold("●") : chalk.magenta("●"); + const suffix = isCurrent ? ` ${chalk.green("◂ current")}` : ""; + return `${bullet} ${chalk.yellow(id)} expires: ${formatExpires(expiresAt)}${suffix}`; +} + +function renderSiblings(siblings: SnapshotData[], count: string): string[] { + const lines: string[] = []; + const maxShow = 5; + const shown = siblings.slice(0, maxShow); + const totalCount = parseInt(count); + const hasPlus = count.endsWith("+"); + const totalSiblings = totalCount - 1; // count includes the main snapshot + const remaining = totalSiblings - shown.length; + + for (let i = 0; i < shown.length; i++) { + const isLast = i === shown.length - 1 && remaining <= 0; + const connector = isLast ? "╰──" : "├──"; + lines.push(`│ ${connector} ${chalk.gray(shown[i].sourceSessionId)}`); + } + + if (remaining > 0) { + const suffix = hasPlus ? "+" : ""; + lines.push( + `│ ╰── ${chalk.gray(`+${remaining}${suffix} more sandboxes`)}`, + ); + } + + return lines; +} + +export function renderSnapshotTree( + params: RenderSnapshotTreeParams, +): string { + const { + currentSnapshotId, + currentSnapshotExpiresAt, + ancestors, + descendants, + hideCurrent, + } = params; + const lines: string[] = []; + + // Helper: push a snapshot node with optional siblings and a trailing connector + const pushNode = ( + node: TreeNode, + isCurrent: boolean, + ) => { + lines.push("│"); + lines.push( + renderNode(node.snapshot.id, node.snapshot.expiresAt, isCurrent), + ); + if (node.siblings.length > 0) { + lines.push(...renderSiblings(node.siblings, node.count)); + } + }; + + // Descendants (newest at top, reversed from asc order) + const descendantNodes = descendants.snapshots.filter( + (n) => n.snapshot.id !== currentSnapshotId, + ); + if (descendantNodes.length > 0) { + for (const node of [...descendantNodes].reverse()) { + pushNode(node, false); + } + } + + // Current snapshot + if (!hideCurrent) { + const currentTreeNode = + ancestors.snapshots.find( + (n) => n.snapshot.id === currentSnapshotId, + ) ?? + descendants.snapshots.find( + (n) => n.snapshot.id === currentSnapshotId, + ); + const currentNode: TreeNode = currentTreeNode ?? { + snapshot: { + id: currentSnapshotId, + sourceSessionId: "", + expiresAt: currentSnapshotExpiresAt, + }, + siblings: [], + count: "1", + }; + pushNode(currentNode, true); + } + + // Ancestors (parent at top, root at bottom) + const ancestorNodes = ancestors.snapshots.filter( + (n) => n.snapshot.id !== currentSnapshotId, + ); + for (const node of ancestorNodes) { + pushNode(node, false); + } + + // Root marker: show when we've reached the end of ancestor pagination + const lastAncestor = ancestorNodes[ancestorNodes.length - 1]; + const hasReachedEnd = ancestors.pagination.next === null; + if (hasReachedEnd) { + if ( + (lastAncestor && !lastAncestor.snapshot.parentId) || + ancestorNodes.length === 0 + ) { + lines.push("│"); + lines.push(`${chalk.white("○")} ${chalk.gray("(root)")}`); + } + } + + return lines.join("\n"); +} diff --git a/packages/vercel-sandbox/src/api-client/api-client.test.ts b/packages/vercel-sandbox/src/api-client/api-client.test.ts index 2dd1a2e5..41b4b7d1 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.test.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.test.ts @@ -803,6 +803,115 @@ describe("APIClient", () => { }); }); + describe("getSnapshotTree", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSnapshot = (overrides: Partial> = {}) => ({ + id: "snap_123", + sourceSessionId: "sbx_123", + region: "iad1", + status: "created", + sizeBytes: 1024, + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("fetches tree with required snapshotId and projectId", async () => { + const body = { + snapshots: [ + { + snapshot: makeSnapshot({ id: "snap_parent", parentId: "snap_root" }), + siblings: [], + count: "1", + }, + ], + pagination: { count: 1, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.getSnapshotTree({ + projectId: "proj_123", + snapshotId: "snap_123", + }); + + expect(result.json.snapshots).toHaveLength(1); + expect(result.json.snapshots[0].snapshot.id).toBe("snap_parent"); + expect(result.json.snapshots[0].count).toBe("1"); + expect(result.json.pagination.count).toBe(1); + expect(result.json.pagination.next).toBeNull(); + + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/sandboxes/snapshots/tree"); + expect(url).toContain("project=proj_123"); + expect(url).toContain("snapshotId=snap_123"); + expect(url).toContain("teamId=team_123"); + expect(opts.method).toBe("GET"); + }); + + it("passes limit and sortOrder query params", async () => { + const body = { + snapshots: [], + pagination: { count: 0, next: null }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.getSnapshotTree({ + projectId: "proj_123", + snapshotId: "snap_123", + limit: 5, + sortOrder: "asc", + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("limit=5"); + expect(url).toContain("sortOrder=asc"); + }); + + it("parses pagination.next for continuation", async () => { + const body = { + snapshots: [ + { + snapshot: makeSnapshot({ id: "snap_a", parentId: "snap_b" }), + siblings: [], + count: "1", + }, + ], + pagination: { count: 1, next: "snap_a" }, + }; + mockFetch.mockResolvedValue( + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.getSnapshotTree({ + projectId: "proj_123", + snapshotId: "snap_123", + }); + + expect(result.json.pagination.next).toBe("snap_a"); + }); + }); + describe("updateSandbox", () => { let client: APIClient; let mockFetch: ReturnType; diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index 3d8c6e7b..04f6c68b 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -17,6 +17,7 @@ import { type LogLineStdout, type LogLineStderr, SnapshotsResponse, + SnapshotTreeResponse, SnapshotResponse, CreateSnapshotResponse, SandboxAndSessionResponse, @@ -482,6 +483,40 @@ export class APIClient extends BaseClient { ); } + async getSnapshotTree(params: { + /** + * The ID or name of the project to which the snapshots belong. + */ + projectId: string; + /** + * The snapshot ID to use as the anchor for the tree traversal. + */ + snapshotId: string; + /** + * Maximum number of nodes to return. + */ + limit?: number; + /** + * Sort order: "asc" for descendants, "desc" for ancestors. + */ + sortOrder?: "asc" | "desc"; + signal?: AbortSignal; + }) { + return parseOrThrow( + SnapshotTreeResponse, + await this.request(`/v2/sandboxes/snapshots/tree`, { + query: { + project: params.projectId, + snapshotId: params.snapshotId, + limit: params.limit, + sortOrder: params.sortOrder, + }, + method: "GET", + signal: params.signal, + }), + ); + } + async writeFiles(params: { sessionId: string; cwd: string; diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index 93edb031..8a4360e5 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -141,6 +141,9 @@ export const Snapshot = z.object({ expiresAt: z.number().optional(), createdAt: z.number(), updatedAt: z.number(), + lastUsedAt: z.number().optional(), + creationMethod: z.string().optional(), + parentId: z.string().optional(), }); export const CursorPagination = z.object({ @@ -216,6 +219,19 @@ export const SnapshotsResponse = z.object({ pagination: CursorPagination, }); +export const SnapshotTreeNode = z.object({ + snapshot: Snapshot, + siblings: z.array(Snapshot), + count: z.string(), +}); + +export type SnapshotTreeNodeData = z.infer; + +export const SnapshotTreeResponse = z.object({ + snapshots: z.array(SnapshotTreeNode), + pagination: CursorPagination, +}); + export const CreateSnapshotResponse = z.object({ snapshot: Snapshot, session: Session.passthrough(), diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 682bf74c..58e388df 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -13,6 +13,7 @@ export { export type { SerializedSandbox } from "./sandbox.js"; export { Snapshot } from "./snapshot.js"; export type { SerializedSnapshot } from "./snapshot.js"; +export type { SnapshotTreeNodeData } from "./api-client/validators.js"; export { Command, CommandFinished } from "./command.js"; export type { SerializedCommand, diff --git a/packages/vercel-sandbox/src/snapshot.ts b/packages/vercel-sandbox/src/snapshot.ts index 4b6f6f22..61c59468 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -223,6 +223,52 @@ export class Snapshot { return snapshotId; } + /** + * Fetch the snapshot ancestry tree for a given snapshot. + * Returns both the tree nodes and pagination metadata. + * + * The returned object is async-iterable to auto-paginate through all pages + * in the direction set by `sortOrder`: + * + * ```ts + * const result = await Snapshot.tree({ snapshotId: "snap_abc", sortOrder: "desc" }); + * for await (const node of result) { ... } + * // or: await result.toArray(); + * // or: for await (const page of result.pages()) { ... } + * ``` + */ + static async tree( + params: { + snapshotId: string; + limit?: number; + sortOrder?: "asc" | "desc"; + } & Partial[0]> & + Partial & + WithFetchOptions, + ) { + "use step"; + const credentials = await getCredentials(params); + const client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params?.fetch, + }); + const fetchPage = async (snapshotId: string) => { + const response = await client.getSnapshotTree({ + ...credentials, + ...params, + snapshotId, + }); + return response.json; + }; + const firstPage = await fetchPage(params.snapshotId); + return attachPaginator(firstPage, { + itemsKey: "snapshots", + fetchNext: fetchPage, + signal: params.signal, + }); + } + /** * Retrieve an existing snapshot. * From a83ade85b05b322e7693e20061867c18368d297a Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 14 May 2026 14:50:11 +0200 Subject: [PATCH 2/5] add changeset --- .changeset/vast-moles-train.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/vast-moles-train.md diff --git a/.changeset/vast-moles-train.md b/.changeset/vast-moles-train.md new file mode 100644 index 00000000..1bf477d4 --- /dev/null +++ b/.changeset/vast-moles-train.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Support snapshot tree pagination From e715579acf1864e941f5d9b5fca13e48d608a1ec Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 14 May 2026 16:00:50 +0200 Subject: [PATCH 3/5] fixes --- packages/sandbox/src/commands/snapshots.ts | 11 ++- .../sandbox/src/util/snapshot-tree.test.ts | 2 - packages/sandbox/src/util/snapshot-tree.ts | 78 ++++++++++--------- packages/vercel-sandbox/src/snapshot.ts | 2 +- 4 files changed, 51 insertions(+), 42 deletions(-) diff --git a/packages/sandbox/src/commands/snapshots.ts b/packages/sandbox/src/commands/snapshots.ts index 981b9b32..33d12e71 100644 --- a/packages/sandbox/src/commands/snapshots.ts +++ b/packages/sandbox/src/commands/snapshots.ts @@ -197,7 +197,7 @@ const tree = cmd.command({ direction: cmd.option({ long: "direction", description: - "Pagination direction (default desc). 'desc' = ancestors, 'asc' = descendants. Only used with --cursor.", + "Pagination direction (default desc). 'desc' = ancestors, 'asc' = descendants. Requires --cursor.", type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), }), }, @@ -216,6 +216,14 @@ const tree = cmd.command({ return; } + if (direction !== undefined && !cursor) { + console.error( + chalk.red("Error: --direction requires --cursor."), + ); + process.exitCode = 1; + return; + } + const pageLimit = limit ?? 10; // Paginated single-direction branch. @@ -247,7 +255,6 @@ const tree = cmd.command({ console.log( renderSnapshotTree({ - currentSnapshotId: "", hideCurrent: true, ancestors, descendants, diff --git a/packages/sandbox/src/util/snapshot-tree.test.ts b/packages/sandbox/src/util/snapshot-tree.test.ts index ff23107e..95ce7b8e 100644 --- a/packages/sandbox/src/util/snapshot-tree.test.ts +++ b/packages/sandbox/src/util/snapshot-tree.test.ts @@ -406,7 +406,6 @@ describe("renderSnapshotTree", () => { const plain = strip( renderSnapshotTree({ - currentSnapshotId: "snap_anchor_already_shown", hideCurrent: true, ancestors: { snapshots: [makeTreeNode(parent), makeTreeNode(grandparent)], @@ -418,7 +417,6 @@ describe("renderSnapshotTree", () => { expect(plain).toContain("snap_parent"); expect(plain).toContain("snap_grandparent"); - expect(plain).not.toContain("snap_anchor_already_shown"); expect(plain).not.toContain("◂ current"); const idxParent = plain.indexOf("snap_parent"); diff --git a/packages/sandbox/src/util/snapshot-tree.ts b/packages/sandbox/src/util/snapshot-tree.ts index a5eb4615..c8cdad9d 100644 --- a/packages/sandbox/src/util/snapshot-tree.ts +++ b/packages/sandbox/src/util/snapshot-tree.ts @@ -19,18 +19,24 @@ interface TreeResponse { pagination: { count: number; next: string | null }; } -export interface RenderSnapshotTreeParams { - currentSnapshotId: string; - currentSnapshotExpiresAt?: number; - ancestors: TreeResponse; - descendants: TreeResponse; - /** - * When true, suppress the "current" snapshot node. Used when rendering a - * single-direction paginated view, where the anchor was already shown on - * the previous page. - */ - hideCurrent?: boolean; -} +export type RenderSnapshotTreeParams = + | { + currentSnapshotId: string; + currentSnapshotExpiresAt?: number; + ancestors: TreeResponse; + descendants: TreeResponse; + hideCurrent?: false; + } + | { + /** + * Suppress the "current" snapshot node. Used when rendering a + * single-direction paginated view, where the anchor was already shown + * on the previous page. + */ + hideCurrent: true; + ancestors: TreeResponse; + descendants: TreeResponse; + }; function formatExpires(expiresAt: number | undefined): string { if (expiresAt === undefined) { @@ -60,7 +66,7 @@ function renderSiblings(siblings: SnapshotData[], count: string): string[] { const lines: string[] = []; const maxShow = 5; const shown = siblings.slice(0, maxShow); - const totalCount = parseInt(count); + const totalCount = parseInt(count, 10); const hasPlus = count.endsWith("+"); const totalSiblings = totalCount - 1; // count includes the main snapshot const remaining = totalSiblings - shown.length; @@ -84,13 +90,9 @@ function renderSiblings(siblings: SnapshotData[], count: string): string[] { export function renderSnapshotTree( params: RenderSnapshotTreeParams, ): string { - const { - currentSnapshotId, - currentSnapshotExpiresAt, - ancestors, - descendants, - hideCurrent, - } = params; + const { ancestors, descendants } = params; + const hideCurrent = params.hideCurrent === true; + const currentSnapshotId = hideCurrent ? undefined : params.currentSnapshotId; const lines: string[] = []; // Helper: push a snapshot node with optional siblings and a trailing connector @@ -119,16 +121,13 @@ export function renderSnapshotTree( // Current snapshot if (!hideCurrent) { + const { currentSnapshotId: id, currentSnapshotExpiresAt } = params; const currentTreeNode = - ancestors.snapshots.find( - (n) => n.snapshot.id === currentSnapshotId, - ) ?? - descendants.snapshots.find( - (n) => n.snapshot.id === currentSnapshotId, - ); + ancestors.snapshots.find((n) => n.snapshot.id === id) ?? + descendants.snapshots.find((n) => n.snapshot.id === id); const currentNode: TreeNode = currentTreeNode ?? { snapshot: { - id: currentSnapshotId, + id, sourceSessionId: "", expiresAt: currentSnapshotExpiresAt, }, @@ -146,16 +145,21 @@ export function renderSnapshotTree( pushNode(node, false); } - // Root marker: show when we've reached the end of ancestor pagination - const lastAncestor = ancestorNodes[ancestorNodes.length - 1]; - const hasReachedEnd = ancestors.pagination.next === null; - if (hasReachedEnd) { - if ( - (lastAncestor && !lastAncestor.snapshot.parentId) || - ancestorNodes.length === 0 - ) { - lines.push("│"); - lines.push(`${chalk.white("○")} ${chalk.gray("(root)")}`); + // Root marker: only meaningful in the bidirectional (non-paginated) view. + // In a single-direction paginated view we can't tell whether the root has + // been reached from ancestors alone, and it would be wrong to render + // "(root)" when paginating descendants. + if (!hideCurrent) { + const lastAncestor = ancestorNodes[ancestorNodes.length - 1]; + const hasReachedEnd = ancestors.pagination.next === null; + if (hasReachedEnd) { + if ( + (lastAncestor && !lastAncestor.snapshot.parentId) || + ancestorNodes.length === 0 + ) { + lines.push("│"); + lines.push(`${chalk.white("○")} ${chalk.gray("(root)")}`); + } } } diff --git a/packages/vercel-sandbox/src/snapshot.ts b/packages/vercel-sandbox/src/snapshot.ts index 61c59468..a22e2c84 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -224,7 +224,7 @@ export class Snapshot { } /** - * Fetch the snapshot ancestry tree for a given snapshot. + * Fetch the snapshot ancestry tree for a given snapshot. * Returns both the tree nodes and pagination metadata. * * The returned object is async-iterable to auto-paginate through all pages From 19bb9927b20af51d7be6ffa76ae4b19ae6b39275 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Thu, 14 May 2026 16:39:23 +0200 Subject: [PATCH 4/5] rename direction to sortOrder --- packages/sandbox/src/commands/snapshots.ts | 32 +++++++++++----------- packages/vercel-sandbox/src/snapshot.ts | 17 ++++++------ 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/sandbox/src/commands/snapshots.ts b/packages/sandbox/src/commands/snapshots.ts index 33d12e71..9fdc3dc2 100644 --- a/packages/sandbox/src/commands/snapshots.ts +++ b/packages/sandbox/src/commands/snapshots.ts @@ -182,10 +182,16 @@ const tree = cmd.command({ type: sandboxName, description: "Sandbox name", }), + sortOrder: cmd.option({ + long: "sort-order", + description: + "Sort order. Options: asc, desc (default). 'desc' walks ancestors, 'asc' walks descendants. Requires --cursor.", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), limit: cmd.option({ long: "limit", description: - "Maximum number of snapshots per page and direction (1–10, default 10).", + "Maximum number of snapshots per page (1–10, default 10).", type: cmd.optional(cmd.number), }), cursor: cmd.option({ @@ -194,19 +200,13 @@ const tree = cmd.command({ "Pagination cursor from a previous 'More ancestors' or 'More descendants' hint.", type: cmd.optional(cmd.string), }), - direction: cmd.option({ - long: "direction", - description: - "Pagination direction (default desc). 'desc' = ancestors, 'asc' = descendants. Requires --cursor.", - type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), - }), }, async handler({ scope: { token, team, project }, sandboxName: name, + sortOrder, limit, cursor, - direction, }) { if (limit !== undefined && (limit < 1 || limit > 10)) { console.error( @@ -216,9 +216,9 @@ const tree = cmd.command({ return; } - if (direction !== undefined && !cursor) { + if (sortOrder !== undefined && !cursor) { console.error( - chalk.red("Error: --direction requires --cursor."), + chalk.red("Error: --sort-order requires --cursor."), ); process.exitCode = 1; return; @@ -228,7 +228,7 @@ const tree = cmd.command({ // Paginated single-direction branch. if (cursor) { - const sortOrder = direction ?? "desc"; + const effectiveSortOrder = sortOrder ?? "desc"; const page = await (async () => { using _spinner = acquireRelease( () => ora("Fetching snapshot tree...").start(), @@ -236,7 +236,7 @@ const tree = cmd.command({ ); return snapshotClient.tree({ snapshotId: cursor, - sortOrder, + sortOrder: effectiveSortOrder, limit: pageLimit, token, teamId: team, @@ -245,11 +245,11 @@ const tree = cmd.command({ })(); const ancestors = - sortOrder === "desc" + effectiveSortOrder === "desc" ? page : { snapshots: [], pagination: { count: 0, next: null } }; const descendants = - sortOrder === "asc" + effectiveSortOrder === "asc" ? page : { snapshots: [], pagination: { count: 0, next: null } }; @@ -336,12 +336,12 @@ const tree = cmd.command({ const limitArg = limit !== undefined ? ` --limit ${limit}` : ""; if (result.ancestors.pagination.next !== null) { console.log( - `\nMore ancestors: sandbox snapshots tree ${name} --direction desc${limitArg} --cursor ${result.ancestors.pagination.next}`, + `\nMore ancestors: sandbox snapshots tree ${name} --sort-order desc${limitArg} --cursor ${result.ancestors.pagination.next}`, ); } if (result.descendants.pagination.next !== null) { console.log( - `More descendants: sandbox snapshots tree ${name} --direction asc${limitArg} --cursor ${result.descendants.pagination.next}`, + `More descendants: sandbox snapshots tree ${name} --sort-order asc${limitArg} --cursor ${result.descendants.pagination.next}`, ); } }, diff --git a/packages/vercel-sandbox/src/snapshot.ts b/packages/vercel-sandbox/src/snapshot.ts index a22e2c84..bab78a64 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -224,11 +224,13 @@ export class Snapshot { } /** - * Fetch the snapshot ancestry tree for a given snapshot. - * Returns both the tree nodes and pagination metadata. + * Fetch the snapshot ancestry tree anchored on a given snapshot. + * It returns both the tree nodes and the pagination metadata to allow + * walking the next page of results in the same direction. * * The returned object is async-iterable to auto-paginate through all pages - * in the direction set by `sortOrder`: + * in the direction set by `sortOrder` (`"desc"` walks ancestors, `"asc"` + * walks descendants): * * ```ts * const result = await Snapshot.tree({ snapshotId: "snap_abc", sortOrder: "desc" }); @@ -238,11 +240,8 @@ export class Snapshot { * ``` */ static async tree( - params: { - snapshotId: string; - limit?: number; - sortOrder?: "asc" | "desc"; - } & Partial[0]> & + params: { snapshotId: string } & + Partial[0]> & Partial & WithFetchOptions, ) { @@ -251,7 +250,7 @@ export class Snapshot { const client = new APIClient({ teamId: credentials.teamId, token: credentials.token, - fetch: params?.fetch, + fetch: params.fetch, }); const fetchPage = async (snapshotId: string) => { const response = await client.getSnapshotTree({ From 62bcf6b7caf5763e16c2770307d48f3bdb5d69c3 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Fri, 15 May 2026 15:48:49 +0200 Subject: [PATCH 5/5] Update packages/sandbox/src/client.ts Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- packages/sandbox/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index a8a53a11..d625bbe6 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -40,7 +40,7 @@ export const snapshotClient: Pick< () => Snapshot.fromSandbox(name, { fetch: fetchWithUserAgent, ...opts }), ), tree: (params) => - withErrorHandling(Snapshot.tree({ fetch: fetchWithUserAgent, ...params })), + withErrorHandling(() => Snapshot.tree({ fetch: fetchWithUserAgent, ...params })), }; const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => {