UNPKG

langsmith

Version:

Client library to connect to the LangSmith Observability and Evaluation Platform.

920 lines (919 loc) 36.4 kB
"use strict"; /** * Main SandboxClient class for interacting with the sandbox server API. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SandboxClient = void 0; const env_js_1 = require("../utils/env.cjs"); const fetch_js_1 = require("../singletons/fetch.cjs"); const async_caller_js_1 = require("../utils/async_caller.cjs"); const index_js_1 = require("../_openapi_client/index.cjs"); const sandbox_js_1 = require("./sandbox.cjs"); const errors_js_1 = require("./errors.cjs"); const helpers_js_1 = require("./helpers.cjs"); const mounts_js_1 = require("./mounts.cjs"); const index_js_2 = require("../utils/uuid/src/index.cjs"); /** * Sleep that can be interrupted by an AbortSignal. * Resolves after `ms` milliseconds or rejects immediately if the signal fires. */ function sleepWithSignal(ms, signal) { if (!signal) { return new Promise((resolve) => setTimeout(resolve, ms)); } const abortSignal = signal; abortSignal.throwIfAborted(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { abortSignal.removeEventListener("abort", onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); reject(abortSignal.reason); } abortSignal.addEventListener("abort", onAbort, { once: true }); }); } /** * Get the default sandbox API endpoint from environment. * * Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT). */ function getDefaultApiEndpoint() { const base = (0, env_js_1.getLangSmithEnvironmentVariable)("ENDPOINT") ?? "https://api.smith.langchain.com"; return `${base.replace(/\/$/, "")}/v2/sandboxes`; } /** * Get the default API key from environment. */ function getDefaultApiKey() { return (0, env_js_1.getLangSmithEnvironmentVariable)("API_KEY"); } function shellQuote(value) { return `'${value.replace(/'/g, "'\\''")}'`; } function writeString(header, value, offset, length) { header.write(value.slice(0, length), offset, length, "utf8"); } function writeOctal(header, value, offset, length) { const octal = value.toString(8).padStart(length - 1, "0"); header.write(octal.slice(-length + 1) + "\0", offset, length, "ascii"); } function splitTarPath(name) { if (Buffer.byteLength(name) <= 100) { return { name, prefix: "" }; } const parts = name.split("/"); for (let i = 1; i < parts.length; i += 1) { const prefix = parts.slice(0, i).join("/"); const basename = parts.slice(i).join("/"); if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(basename) <= 100) { return { name: basename, prefix }; } } throw new Error(`Docker build context path is too long for tar: ${name}`); } function makeTarHeader(args) { const header = Buffer.alloc(512, 0); const split = splitTarPath(args.name); writeString(header, split.name, 0, 100); writeOctal(header, args.mode, 100, 8); writeOctal(header, 0, 108, 8); writeOctal(header, 0, 116, 8); writeOctal(header, args.size, 124, 12); writeOctal(header, Math.floor(args.mtimeMs / 1000), 136, 12); header.fill(" ", 148, 156); writeString(header, args.type === "directory" ? "5" : args.type === "symlink" ? "2" : "0", 156, 1); if (args.linkName) { writeString(header, args.linkName, 157, 100); } writeString(header, "ustar", 257, 6); writeString(header, "00", 263, 2); if (split.prefix) { writeString(header, split.prefix, 345, 155); } let checksum = 0; for (const byte of header) { checksum += byte; } header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "ascii"); return header; } async function makeDockerContextTar(contextPath) { const fs = await import("node:fs/promises"); const path = await import("node:path"); const contextRoot = path.resolve(contextPath); const chunks = []; async function addEntry(absPath) { const rel = path.relative(contextRoot, absPath); if (!rel || rel.split(path.sep).includes(".git")) { return; } const tarPath = rel.split(path.sep).join("/"); const stat = await fs.lstat(absPath); if (stat.isDirectory()) { chunks.push(makeTarHeader({ name: tarPath.endsWith("/") ? tarPath : `${tarPath}/`, mode: stat.mode & 0o777, size: 0, type: "directory", mtimeMs: stat.mtimeMs, })); const entries = await fs.readdir(absPath); for (const entry of entries.sort()) { await addEntry(path.join(absPath, entry)); } return; } if (stat.isSymbolicLink()) { chunks.push(makeTarHeader({ name: tarPath, mode: stat.mode & 0o777, size: 0, type: "symlink", linkName: await fs.readlink(absPath), mtimeMs: stat.mtimeMs, })); return; } if (!stat.isFile()) { return; } const content = await fs.readFile(absPath); chunks.push(makeTarHeader({ name: tarPath, mode: stat.mode & 0o777, size: content.byteLength, type: "file", mtimeMs: stat.mtimeMs, }), content); const padding = (512 - (content.byteLength % 512)) % 512; if (padding) { chunks.push(Buffer.alloc(padding, 0)); } } const rootEntries = await fs.readdir(contextRoot); for (const entry of rootEntries.sort()) { await addEntry(path.join(contextRoot, entry)); } chunks.push(Buffer.alloc(1024, 0)); return new Uint8Array(Buffer.concat(chunks)); } async function resolveDockerfileContext(dockerfile, context) { const fs = await import("node:fs/promises"); const path = await import("node:path"); const contextPath = path.resolve(context); const dockerfilePath = path.resolve(contextPath, dockerfile); const contextStat = await fs.stat(contextPath); if (!contextStat.isDirectory()) { throw new Error(`context must be a directory: ${contextPath}`); } const dockerfileStat = await fs.stat(dockerfilePath); if (!dockerfileStat.isFile()) { throw new Error(`dockerfile must be a file: ${dockerfilePath}`); } const dockerfileRel = path.relative(contextPath, dockerfilePath); if (dockerfileRel === "" || dockerfileRel.startsWith("..") || path.isAbsolute(dockerfileRel)) { throw new Error("dockerfile must be inside context"); } return { contextPath, dockerfileRel: dockerfileRel.split(path.sep).join("/"), }; } function makeDockerfileBuildCommand(args) { const dockerfileRemote = `${args.remoteContext}/${args.dockerfileRel}`; const dockerfileDir = dockerfileRemote.split("/").slice(0, -1).join("/"); const dockerfileName = dockerfileRemote.split("/").at(-1) ?? "Dockerfile"; const socketPath = `${args.buildkitRun}/buildkitd.sock`; const buildctl = [ "buildctl", "--addr", `unix://${socketPath}`, "build", "--progress=plain", "--frontend", "dockerfile.v0", "--local", `context=${args.remoteContext}`, "--local", `dockerfile=${dockerfileDir}`, "--opt", `filename=${dockerfileName}`, "--output", `type=docker,name=${args.imageRef}`, ]; if (args.target !== undefined) { buildctl.push("--opt", `target=${args.target}`); } for (const [key, value] of Object.entries(args.buildArgs ?? {}).sort()) { buildctl.push("--opt", `build-arg:${key}=${value}`); } return [ "set -euo pipefail", `mkdir -p ${shellQuote(args.buildkitRoot)} ${shellQuote(args.buildkitRun)}`, `buildkitd --addr ${shellQuote(`unix://${socketPath}`)} --root ${shellQuote(args.buildkitRoot)} --oci-worker=true --containerd-worker=false --oci-worker-snapshotter=native --oci-worker-binary buildkit-runc > ${shellQuote(`${args.buildkitRun}/buildkitd.log`)} 2>&1 &`, "buildkitd_pid=$!", 'cleanup() { kill "$buildkitd_pid" >/dev/null 2>&1 || true; }', "trap cleanup EXIT", "for i in $(seq 1 300); do", ` if buildctl --addr ${shellQuote(`unix://${socketPath}`)} debug workers >/dev/null 2>&1; then break; fi`, ` if ! kill -0 "$buildkitd_pid" >/dev/null 2>&1; then cat ${shellQuote(`${args.buildkitRun}/buildkitd.log`)}; exit 1; fi`, ` if [ "$i" = 300 ]; then cat ${shellQuote(`${args.buildkitRun}/buildkitd.log`)}; exit 1; fi`, " sleep 0.1", "done", "for i in $(seq 1 300); do", " if docker info >/dev/null 2>&1; then break; fi", ' if [ "$i" = 300 ]; then docker info; exit 1; fi', " sleep 0.1", "done", `${buildctl.map(shellQuote).join(" ")} | docker load`, `rm -rf ${shellQuote(args.buildkitRoot)} || true`, "", ].join("\n"); } /** * Client for interacting with the Sandbox Server API. * * This client provides a simple interface for managing sandboxes and snapshots. * * @example * ```typescript * import { SandboxClient } from "langsmith/sandbox"; * * // Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment * const client = new SandboxClient(); * * // Or with explicit configuration * const client = new SandboxClient({ * apiEndpoint: "https://api.smith.langchain.com/v2/sandboxes", * apiKey: "your-api-key", * }); * * // Create a sandbox with the default runtime * const sandbox = await client.createSandbox(); * try { * const result = await sandbox.run("python --version"); * console.log(result.stdout); * } finally { * await sandbox.delete(); * } * ``` */ class SandboxClient { constructor(config = {}) { Object.defineProperty(this, "_baseUrl", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "_apiKey", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "_defaultHeaders", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "_fetchImpl", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "_caller", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "_registriesClient", { enumerable: true, configurable: true, writable: true, value: void 0 }); this._baseUrl = (config.apiEndpoint ?? getDefaultApiEndpoint()).replace(/\/$/, ""); this._apiKey = config.apiKey ?? getDefaultApiKey(); this._defaultHeaders = { ...(config.headers ?? {}) }; this._fetchImpl = (0, fetch_js_1._getFetchImplementation)(); this._caller = new async_caller_js_1.AsyncCaller({ maxRetries: config.maxRetries ?? 3, maxConcurrency: config.maxConcurrency ?? Infinity, }); } _apiRoot() { const suffix = "/v2/sandboxes"; return this._baseUrl.endsWith(suffix) ? this._baseUrl.slice(0, -suffix.length) : this._baseUrl; } /** * Manage sandbox image registries: create, list, retrieve, update, delete. * * A registry stores credentials for pulling private images. Create one, then * pass its `id` as `registryId` when building a snapshot. * * @example * ```typescript * const registry = await client.registries.create({ * name: "internal", * url: "registry.example.com", * username: "robot", * password: process.env.REGISTRY_PASSWORD, * }); * const snapshot = await client.createSnapshot( * "internal-python", * "registry.example.com/internal/python:3.12", * 2_147_483_648, * { registryId: registry.id }, * ); * ``` */ get registries() { if (!this._registriesClient) { const defaultHeaders = { ...this._defaultHeaders, }; if (this._apiKey === undefined) { defaultHeaders["X-API-Key"] = null; } this._registriesClient = new index_js_1.Langsmith({ apiKey: this._apiKey, baseURL: this._apiRoot(), defaultHeaders, fetch: this._fetchImpl, }); } return this._registriesClient.sandboxes.registries; } /** * Internal fetch method that adds authentication headers. * * Uses AsyncCaller to handle retries for transient failures * (network errors, 5xx, 429). * * @internal */ async _fetch(url, init = {}) { const headers = new Headers(init.headers); if (this._apiKey) { headers.set("X-Api-Key", this._apiKey); } for (const [name, value] of Object.entries(this._defaultHeaders)) { if (!headers.has(name)) { headers.set(name, value); } } return this._caller.call(() => this._fetchImpl(url, { ...init, headers, })); } /** * Get the API key for WebSocket authentication. * @internal */ getApiKey() { return this._apiKey; } /** * Get the constructor-supplied default headers. Used by the WebSocket exec * path so headers like `X-Service-Key` set on the client are attached to * the WS upgrade request. * @internal */ getDefaultHeaders() { return { ...this._defaultHeaders }; } /** * JSON POST helper. Sends JSON body, checks response status, * and returns the Response for further processing. * Throws on non-ok responses via handleClientHttpError. * Callers can add specific status checks (e.g. 404) before calling this. * @internal */ async _postJson(url, body, options) { const response = await this._fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: options?.signal, }); if (!response.ok) { await (0, helpers_js_1.handleClientHttpError)(response); } return response; } async createSandbox(snapshotIdOrOptions, options = {}) { const snapshotId = typeof snapshotIdOrOptions === "string" ? snapshotIdOrOptions : undefined; const resolvedOptions = typeof snapshotIdOrOptions === "object" && snapshotIdOrOptions !== null ? snapshotIdOrOptions : options; const { snapshotName, name, timeout = 30, waitForReady = true, idleTtlSeconds, deleteAfterStopSeconds, vCpus, memBytes, fsCapacityBytes, mountConfig, proxyConfig, } = resolvedOptions; if (snapshotId && snapshotName) { throw new errors_js_1.LangSmithValidationError("At most one of snapshotId or options.snapshotName may be set", "snapshotId"); } (0, helpers_js_1.validateTtl)(idleTtlSeconds, "idleTtlSeconds"); (0, helpers_js_1.validateTtl)(deleteAfterStopSeconds, "deleteAfterStopSeconds"); const url = `${this._baseUrl}/boxes`; const payload = { wait_for_ready: waitForReady, }; if (snapshotId) { payload.snapshot_id = snapshotId; } if (snapshotName) { payload.snapshot_name = snapshotName; } if (waitForReady) { payload.timeout = timeout; } if (name) { payload.name = name; } if (idleTtlSeconds !== undefined) { payload.idle_ttl_seconds = idleTtlSeconds; } if (deleteAfterStopSeconds !== undefined) { payload.delete_after_stop_seconds = deleteAfterStopSeconds; } if (vCpus !== undefined) { payload.vcpus = vCpus; } if (memBytes !== undefined) { payload.mem_bytes = memBytes; } if (fsCapacityBytes !== undefined) { payload.fs_capacity_bytes = fsCapacityBytes; } if (mountConfig !== undefined) { (0, mounts_js_1.validateMountConfigProxyConfig)(mountConfig, proxyConfig); payload.mount_config = mountConfig; } if (proxyConfig !== undefined) { payload.proxy_config = proxyConfig; } const httpTimeout = waitForReady ? (timeout + 30) * 1000 : 30 * 1000; const response = await this._fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(httpTimeout), }); if (!response.ok) { await (0, helpers_js_1.handleSandboxCreationError)(response); } const data = (await response.json()); return new sandbox_js_1.Sandbox(data, this); } /** * Get a Sandbox by name. * * The sandbox is NOT automatically deleted. Use deleteSandbox() for cleanup. * * @param name - Sandbox name. * @returns Sandbox. * @throws LangSmithResourceNotFoundError if sandbox not found. */ async getSandbox(name, options) { const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`; const response = await this._fetch(url, { signal: options?.signal }); if (!response.ok) { if (response.status === 404) { throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } await (0, helpers_js_1.handleClientHttpError)(response); } const data = (await response.json()); return new sandbox_js_1.Sandbox(data, this); } /** * List all Sandboxes. * * @returns List of Sandboxes. */ async listSandboxes() { const url = `${this._baseUrl}/boxes`; const response = await this._fetch(url); if (!response.ok) { if (response.status === 404) { throw new errors_js_1.LangSmithSandboxAPIError(`API endpoint not found: ${url}. Check that apiEndpoint is correct.`); } await (0, helpers_js_1.handleClientHttpError)(response); } const data = await response.json(); return (data.sandboxes ?? []).map((s) => new sandbox_js_1.Sandbox(s, this)); } async updateSandbox(name, newNameOrOptions) { const options = typeof newNameOrOptions === "string" ? { newName: newNameOrOptions } : newNameOrOptions; const { newName, idleTtlSeconds, deleteAfterStopSeconds } = options; (0, helpers_js_1.validateTtl)(idleTtlSeconds, "idleTtlSeconds"); (0, helpers_js_1.validateTtl)(deleteAfterStopSeconds, "deleteAfterStopSeconds"); if (newName === undefined && idleTtlSeconds === undefined && deleteAfterStopSeconds === undefined) { return this.getSandbox(name); } const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`; const payload = {}; if (newName !== undefined) { payload.name = newName; } if (idleTtlSeconds !== undefined) { payload.idle_ttl_seconds = idleTtlSeconds; } if (deleteAfterStopSeconds !== undefined) { payload.delete_after_stop_seconds = deleteAfterStopSeconds; } const response = await this._fetch(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { if (response.status === 404) { throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } if (response.status === 409) { throw new errors_js_1.LangSmithResourceNameConflictError(newName !== undefined ? `Sandbox name '${newName}' already in use` : "Sandbox update conflict (name may already be in use)", "sandbox"); } await (0, helpers_js_1.handleClientHttpError)(response); } const data = (await response.json()); return new sandbox_js_1.Sandbox(data, this); } /** * Delete a Sandbox. * * @param name - Sandbox name. * @throws LangSmithResourceNotFoundError if sandbox not found. */ async deleteSandbox(name) { const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}`; const response = await this._fetch(url, { method: "DELETE" }); if (!response.ok) { if (response.status === 404) { throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } await (0, helpers_js_1.handleClientHttpError)(response); } } /** * Get the provisioning status of a sandbox. * * This is a lightweight endpoint designed for polling during async creation. * Use this instead of getSandbox() when you only need the status. * * @param name - Sandbox name. * @returns ResourceStatus with status and optional status_message. * @throws LangSmithResourceNotFoundError if sandbox not found. */ async getSandboxStatus(name, options) { const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/status`; const response = await this._fetch(url, { signal: options?.signal }); if (!response.ok) { if (response.status === 404) { throw new errors_js_1.LangSmithResourceNotFoundError(`Sandbox '${name}' not found`, "sandbox"); } await (0, helpers_js_1.handleClientHttpError)(response); } return (await response.json()); } /** * Wait for a sandbox to become ready. * * Polls getSandboxStatus() until the sandbox reaches "ready" or "failed" status, * then returns the full Sandbox object. * * @param name - Sandbox name. * @param options - Polling options (timeout, pollInterval). * @returns Ready Sandbox. * @throws LangSmithResourceCreationError if sandbox status becomes "failed". * @throws LangSmithResourceTimeoutError if timeout expires while still provisioning. * @throws LangSmithResourceNotFoundError if sandbox not found. * * @example * ```typescript * const sandbox = await client.createSandbox(snapshot.id, { waitForReady: false }); * // ... do other work ... * const readySandbox = await client.waitForSandbox(sandbox.name); * ``` */ async waitForSandbox(name, options = {}) { const { timeout = 120, pollInterval = 1.0, signal } = options; const deadline = Date.now() + timeout * 1000; let lastStatus = "provisioning"; while (Date.now() < deadline) { signal?.throwIfAborted(); const statusResult = await this.getSandboxStatus(name, { signal }); lastStatus = statusResult.status; if (statusResult.status === "ready") { return this.getSandbox(name, { signal }); } if (statusResult.status === "failed") { throw new errors_js_1.LangSmithResourceCreationError(statusResult.status_message ?? `Sandbox '${name}' creation failed`, "sandbox"); } // Wait before polling again, capped to remaining time + jitter const remaining = deadline - Date.now(); const jitter = pollInterval * 200 * (Math.random() - 0.5); // ±10% const delay = Math.min(pollInterval * 1000 + jitter, remaining); if (delay > 0) { await sleepWithSignal(delay, signal); } } throw new errors_js_1.LangSmithResourceTimeoutError(`Sandbox '${name}' did not become ready within ${timeout}s`, "sandbox", lastStatus); } /** * Start a stopped sandbox and wait until ready. * * @param name - Sandbox name. * @param options - Options with timeout. * @returns Sandbox in "ready" status. */ async startSandbox(name, options = {}) { const { timeout = 120, signal } = options; const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/start`; await this._postJson(url, {}, { signal }); return this.waitForSandbox(name, { timeout, signal }); } /** * Stop a running sandbox (preserves sandbox files for later restart). * * @param name - Sandbox name. */ async stopSandbox(name) { const url = `${this._baseUrl}/boxes/${encodeURIComponent(name)}/stop`; await this._postJson(url, {}); } // ========================================================================= // Snapshot Operations // ========================================================================= /** * Build a snapshot from a Docker image. * * Blocks until the snapshot is ready (polls with 2s interval). * * @param name - Snapshot name. * @param dockerImage - Docker image to build from (e.g., "python:3.12-slim"). * @param fsCapacityBytes - Filesystem capacity in bytes. * @param options - Additional options (registry ID, timeout). * @returns Snapshot in "ready" status. */ async createSnapshot(name, dockerImage, fsCapacityBytes, options = {}) { const { registryId, timeout = 60, signal } = options; const url = `${this._baseUrl}/snapshots`; const payload = { name, docker_image: dockerImage, fs_capacity_bytes: fsCapacityBytes, }; if (registryId !== undefined) { payload.registry_id = registryId; } const response = await this._postJson(url, payload, { signal }); const snapshot = (await response.json()); return this.waitForSnapshot(snapshot.id, { timeout, signal }); } /** * Build a snapshot from a local Dockerfile context. * * Creates a temporary builder sandbox, uploads the Docker build context, * runs BuildKit inside the sandbox, and captures the built image as a * LangSmith snapshot. * * @param name - Snapshot name. * @param dockerfile - Local Dockerfile path, relative to context by default. * @param fsCapacityBytes - Filesystem capacity in bytes. * @param options - Build context, args, target, build log callback, builder * vCPUs/memory, timeout. * @returns Snapshot in "ready" status. */ async createSnapshotFromDockerfile(name, dockerfile, fsCapacityBytes, options = {}) { const { context = ".", buildArgs, target, onBuildLog, vCpus, memBytes, timeout = 60, } = options; const { contextPath, dockerfileRel } = await resolveDockerfileContext(dockerfile, context); const builderName = `snapshot-builder-${(0, index_js_2.v4)().replace(/-/g, "").slice(0, 12)}`; // Stage the build on the capacity-backed root filesystem, not /tmp. // Inside the sandbox /tmp is a RAM-backed tmpfs that fsCapacityBytes does // not size, and BuildKit's native snapshotter writes a full copy of every // layer under its root, so a /tmp build exhausts guest RAM and fails with // "No space left on device". const buildRoot = `/var/lib/langsmith-build/${(0, index_js_2.v4)() .replace(/-/g, "") .slice(0, 12)}`; const remoteContext = `${buildRoot}/context`; const remoteTar = `${buildRoot}/context.tar`; const imageRef = `langsmith-snapshot-build:${(0, index_js_2.v4)().replace(/-/g, "")}`; const buildkitRoot = `${buildRoot}/buildkit-root`; const buildkitRun = `${buildRoot}/buildkit-run`; const builder = await this.createSandbox({ name: builderName, timeout, vCpus, memBytes, fsCapacityBytes, }); try { await builder.write(remoteTar, await makeDockerContextTar(contextPath), timeout); await builder.run([ `rm -rf ${shellQuote(remoteContext)}`, `mkdir -p ${shellQuote(remoteContext)}`, `tar -xf ${shellQuote(remoteTar)} -C ${shellQuote(remoteContext)}`, ].join(" && "), { timeout }); const result = await builder.run(makeDockerfileBuildCommand({ remoteContext, dockerfileRel, imageRef, buildkitRoot, buildkitRun, buildArgs, target, }), { timeout, onStdout: onBuildLog, onStderr: onBuildLog, }); if (result.exit_code !== 0) { throw new errors_js_1.LangSmithResourceCreationError("Dockerfile snapshot build failed", "snapshot"); } return await this.captureSnapshot(builder.name, name, { dockerImage: imageRef, fsCapacityBytes, timeout, }); } finally { await builder.delete(); } } /** * Capture a snapshot from a running sandbox. * * Blocks until the snapshot is ready (polls with 2s interval). * * @param sandboxName - Name of the sandbox to capture from. * @param name - Snapshot name. * @param options - Capture options (timeout). * @returns Snapshot in "ready" status. */ async captureSnapshot(sandboxName, name, options = {}) { const { dockerImage, fsCapacityBytes, timeout = 60, signal } = options; const url = `${this._baseUrl}/boxes/${encodeURIComponent(sandboxName)}/snapshot`; const payload = { name }; if (dockerImage !== undefined) { payload.docker_image = dockerImage; } if (fsCapacityBytes !== undefined) { payload.fs_capacity_bytes = fsCapacityBytes; } const response = await this._postJson(url, payload, { signal }); const snapshot = (await response.json()); return this.waitForSnapshot(snapshot.id, { timeout, signal }); } /** * Get a snapshot by ID. * * @param snapshotId - Snapshot UUID. * @returns Snapshot. */ async getSnapshot(snapshotId, options) { const url = `${this._baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`; const response = await this._fetch(url, { signal: options?.signal }); if (!response.ok) { if (response.status === 404) { throw new errors_js_1.LangSmithResourceNotFoundError(`Snapshot '${snapshotId}' not found`, "snapshot"); } await (0, helpers_js_1.handleClientHttpError)(response); } return (await response.json()); } /** * List snapshots, optionally filtered and paginated server-side. * * The backend always paginates this endpoint. When `limit` is omitted the * server applies a default page size (currently 50), so a single call is * not guaranteed to return every snapshot visible to the tenant. To iterate * through all results, repeat the call with increasing `offset` values (or * an explicit `limit`) until fewer than `limit` snapshots come back. * * @param options - Optional filter/pagination options. * - `nameContains`: case-insensitive substring match on snapshot name. * - `limit`: page size; must be between 1 and 500 (inclusive). Defaults * to 50 server-side when omitted. * - `offset`: number of snapshots to skip; must be `>= 0`. * * Values outside those ranges are rejected by the server. * @returns A single page of Snapshots matching the provided filters. * * @example * ```typescript * const firstPage = await client.listSnapshots(); * const page = await client.listSnapshots({ * nameContains: "python", * limit: 100, * offset: 0, * }); * ``` */ async listSnapshots(options = {}) { const { nameContains, limit, offset, signal } = options; const params = new URLSearchParams(); if (nameContains !== undefined) { params.set("name_contains", nameContains); } if (limit !== undefined) { params.set("limit", String(limit)); } if (offset !== undefined) { params.set("offset", String(offset)); } const query = params.toString(); const url = query ? `${this._baseUrl}/snapshots?${query}` : `${this._baseUrl}/snapshots`; const response = await this._fetch(url, { signal }); if (!response.ok) { await (0, helpers_js_1.handleClientHttpError)(response); } const data = await response.json(); return (data.snapshots ?? []); } /** * Delete a snapshot. * * @param snapshotId - Snapshot UUID. */ async deleteSnapshot(snapshotId) { const url = `${this._baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`; const response = await this._fetch(url, { method: "DELETE" }); if (!response.ok) { await (0, helpers_js_1.handleClientHttpError)(response); } } /** * Poll until a snapshot reaches "ready" or "failed" status. * * @param snapshotId - Snapshot UUID. * @param options - Polling options (timeout, pollInterval). * @returns Snapshot in "ready" status. */ async waitForSnapshot(snapshotId, options = {}) { const { timeout = 300, pollInterval = 2.0, signal } = options; const deadline = Date.now() + timeout * 1000; let lastStatus = "building"; while (Date.now() < deadline) { signal?.throwIfAborted(); const snapshot = await this.getSnapshot(snapshotId, { signal }); lastStatus = snapshot.status; if (snapshot.status === "ready") { return snapshot; } if (snapshot.status === "failed") { throw new errors_js_1.LangSmithResourceCreationError(snapshot.status_message ?? `Snapshot '${snapshotId}' build failed`, "snapshot"); } // Cap sleep to remaining time + jitter const remaining = deadline - Date.now(); const jitter = pollInterval * 200 * (Math.random() - 0.5); // ±10% const delay = Math.min(pollInterval * 1000 + jitter, remaining); if (delay > 0) { await sleepWithSignal(delay, signal); } } throw new errors_js_1.LangSmithResourceTimeoutError(`Snapshot '${snapshotId}' did not become ready within ${timeout}s`, "snapshot", lastStatus); } /** * Returns a string representation of the SandboxClient instance. * This method is called when the object is converted to a string * or logged, ensuring sensitive information like API keys is not exposed. * * @returns A string representation of the SandboxClient. */ toString() { return `[LangSmithSandboxClient apiEndpoint=${JSON.stringify(this._baseUrl)}]`; } /** * Custom inspect method for Node.js. * This method is called when the object is inspected in the Node.js REPL * or with console.log, ensuring sensitive information like API keys is not exposed. * * @returns A string representation of the SandboxClient for inspection. */ [Symbol.for("nodejs.util.inspect.custom")]() { return this.toString(); } } exports.SandboxClient = SandboxClient;