diff --git a/.changeset/vast-moles-train.md b/.changeset/vast-moles-train.md new file mode 100644 index 0000000..1bf477d --- /dev/null +++ b/.changeset/vast-moles-train.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Support snapshot tree pagination diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index 17205ec..d625bbe 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 c6a45ad..9fdc3dc 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,187 @@ 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", + }), + 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 (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), + }), + }, + async handler({ + scope: { token, team, project }, + sandboxName: name, + sortOrder, + limit, + cursor, + }) { + if (limit !== undefined && (limit < 1 || limit > 10)) { + console.error( + chalk.red("Error: --limit must be between 1 and 10."), + ); + process.exitCode = 1; + return; + } + + if (sortOrder !== undefined && !cursor) { + console.error( + chalk.red("Error: --sort-order requires --cursor."), + ); + process.exitCode = 1; + return; + } + + const pageLimit = limit ?? 10; + + // Paginated single-direction branch. + if (cursor) { + const effectiveSortOrder = sortOrder ?? "desc"; + const page = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching snapshot tree...").start(), + (s) => s.stop(), + ); + return snapshotClient.tree({ + snapshotId: cursor, + sortOrder: effectiveSortOrder, + limit: pageLimit, + token, + teamId: team, + projectId: project, + }); + })(); + + const ancestors = + effectiveSortOrder === "desc" + ? page + : { snapshots: [], pagination: { count: 0, next: null } }; + const descendants = + effectiveSortOrder === "asc" + ? page + : { snapshots: [], pagination: { count: 0, next: null } }; + + console.log( + renderSnapshotTree({ + 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} --sort-order desc${limitArg} --cursor ${result.ancestors.pagination.next}`, + ); + } + if (result.descendants.pagination.next !== null) { + console.log( + `More descendants: sandbox snapshots tree ${name} --sort-order 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 0000000..95ce7b8 --- /dev/null +++ b/packages/sandbox/src/util/snapshot-tree.test.ts @@ -0,0 +1,461 @@ +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({ + 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("◂ 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 0000000..c8cdad9 --- /dev/null +++ b/packages/sandbox/src/util/snapshot-tree.ts @@ -0,0 +1,167 @@ +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 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) { + 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, 10); + 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 { 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 + 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 { currentSnapshotId: id, currentSnapshotExpiresAt } = params; + const currentTreeNode = + ancestors.snapshots.find((n) => n.snapshot.id === id) ?? + descendants.snapshots.find((n) => n.snapshot.id === id); + const currentNode: TreeNode = currentTreeNode ?? { + snapshot: { + id, + 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: 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)")}`); + } + } + } + + 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 2dd1a2e..41b4b7d 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 3d8c6e7..04f6c68 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 93edb03..8a4360e 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 682bf74..58e388d 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 4b6f6f2..bab78a6 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -223,6 +223,51 @@ export class Snapshot { return snapshotId; } + /** + * 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` (`"desc"` walks ancestors, `"asc"` + * walks descendants): + * + * ```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 } & + 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. *