diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1d835d..5025839 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,5 +17,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm fmt:check - - run: pnpm type-check - - run: pnpm build + - run: pnpm type-check:all + - run: pnpm test:all + - run: pnpm build:all diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fae008..e7926a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ # Changelog +## Unreleased + +- Add the beta `@depot/sandbox` package under `packages/sandbox`. + See [GitHub Releases](https://github.com/depot/sdk-node/releases) for the latest changes. diff --git a/README.md b/README.md index 85681ab..52e0e94 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A Node.js SDK for the [Depot](https://depot.dev) API. πŸ‘‰ [**API Documentation**](https://buf.build/depot/api) +This repository also contains the beta [`@depot/sandbox`](./packages/sandbox) package for the `depot.sandbox.v1` API. + ## Installation Use [pnpm](https://pnpm.io) or your favorite package manager: @@ -16,6 +18,12 @@ Use [pnpm](https://pnpm.io) or your favorite package manager: pnpm add @depot/sdk-node ``` +After the sandbox beta is published, install it separately: + +```bash +pnpm add @depot/sandbox@beta +``` + ## Usage Each of the Depot API services is exposed on the main `depot` export. Authentication is provided via an `Authorization` header with an **Organization Token**, for each API request. The service paths match their corresponding gRPC service names. @@ -43,6 +51,21 @@ async function example() { } ``` +### Sandbox Beta + +```typescript +import {createClient, Sandbox} from '@depot/sandbox' + +const client = createClient({token: process.env.DEPOT_TOKEN!}) +const sandbox = await Sandbox.create(client) + +const command = await sandbox.runCommand({cmd: 'echo', args: ['hello']}) +await command.wait() + +console.log(await command.stdout()) +await sandbox.stop({blocking: true}) +``` + ## License MIT License, see `LICENSE`. diff --git a/package.json b/package.json index 4ce2a0b..38ffc8d 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,15 @@ ], "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "build:all": "pnpm run build && pnpm --filter './packages/*' --if-present run build", "clean": "rm -rf dist", "fmt": "prettier --write .", "fmt:check": "prettier --check .", "generate": "buf generate buf.build/depot/api && prettier --write src/gen", "prepack": "pnpm run build", - "type-check": "tsc --noEmit" + "test:all": "pnpm --filter './packages/*' --if-present run test", + "type-check": "tsc --noEmit", + "type-check:all": "pnpm run type-check && pnpm --filter './packages/*' --if-present run type-check" }, "peerDependencies": { "@bufbuild/protobuf": ">=2, <3" diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md new file mode 100644 index 0000000..f99bba6 --- /dev/null +++ b/packages/sandbox/README.md @@ -0,0 +1,67 @@ +# @depot/sandbox + +Beta TypeScript SDK for Depot sandboxes. + +This package wraps the `depot.sandbox.v1` API with Vercel-shaped classes for creating sandboxes, running commands, streaming command output, and using a `node:fs/promises`-shaped filesystem interface. + +## Installation + +After the beta package is published: + +```bash +pnpm add @depot/sandbox@beta +``` + +## Usage + +Set `DEPOT_TOKEN` in your environment, then create a client and pass it to the static sandbox entry points. Returned sandbox instances keep that client, so instance methods do not take a client argument. + +```typescript +import {createClient, Sandbox} from '@depot/sandbox' + +const client = createClient({token: process.env.DEPOT_TOKEN!}) +const sandbox = await Sandbox.create(client, { + env: {NODE_ENV: 'development'}, +}) + +const command = await sandbox.runCommand({cmd: 'echo', args: ['hello from depot']}) +const finished = await command.wait() + +console.log(finished.exitCode) +console.log(await command.stdout()) + +const fs = sandbox.fs() +await fs.writeFile('/tmp/message.txt', 'hello') +console.log(await fs.readFile('/tmp/message.txt', 'utf8')) + +await sandbox.stop({blocking: true}) +``` + +You can also pass organization and endpoint options explicitly: + +```typescript +const client = createClient({ + token: process.env.DEPOT_TOKEN!, + orgID: process.env.DEPOT_ORG_ID, +}) +``` + +## Beta Surface + +This beta package currently includes: + +- `createClient` +- `Sandbox.create`, `Sandbox.get`, `Sandbox.list`, `Sandbox.listAll` +- `sandbox.stop`, `sandbox.kill`, `sandbox.runCommand`, `sandbox.fs` +- `SandboxCommandExecution.wait`, `logs`, `output`, `stdout`, and `stderr` +- `FileSystem` helpers for common file operations + +Other sandbox capabilities, such as piped stdin, command history, create-time secrets, timeout extension, snapshots, and pty support, are not part of this beta surface yet. + +## Generated Protos + +The generated `depot.sandbox.v1` TypeScript files are vendored in `src/gen` for the beta package so customers do not need a separate published proto module. + +## License + +MIT License, see the repository `LICENSE`. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json new file mode 100644 index 0000000..377405f --- /dev/null +++ b/packages/sandbox/package.json @@ -0,0 +1,49 @@ +{ + "name": "@depot/sandbox", + "version": "0.1.0-beta.0", + "type": "module", + "description": "TypeScript SDK for depot.sandbox.v1: create sandboxes, run commands, stream output, and use a node:fs/promises-shaped file system.", + "repository": { + "type": "git", + "url": "https://github.com/depot/sdk-node.git", + "directory": "packages/sandbox" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepack": "pnpm run build", + "test": "tsx --test src/*.test.ts", + "type-check": "tsc --noEmit" + }, + "peerDependencies": { + "@bufbuild/protobuf": ">=2, <3" + }, + "dependencies": { + "@connectrpc/connect": "^2.1.0", + "@connectrpc/connect-node": "^2.1.0" + }, + "devDependencies": { + "@bufbuild/protobuf": "^2.8.0", + "@types/node": "^22.5.4", + "tsx": "^4.20.0", + "typescript": "^5.5.4" + }, + "publishConfig": { + "access": "public", + "tag": "beta" + } +} diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts new file mode 100644 index 0000000..a5161c4 --- /dev/null +++ b/packages/sandbox/src/client.ts @@ -0,0 +1,65 @@ +import {createClient as createConnectClient, type Client} from '@connectrpc/connect' +import {createConnectTransport} from '@connectrpc/connect-node' +import {SandboxService} from './gen/depot/sandbox/v1/sandbox_pb.js' + +/** Default Connect endpoint for the v1 sandbox API. */ +export const DEFAULT_ENDPOINT = 'https://api.depot.dev' + +/** + * Creates a `SandboxClient`. The returned value is an opaque handle that wraps + * the underlying Connect client; pass it to static Sandbox entry points. + * + * ```ts + * const client = createClient({token: process.env.DEPOT_TOKEN!}) + * const sandbox = await Sandbox.create(client) + * ``` + */ +export function createClient(opts: CreateClientOpts): SandboxClient { + if (!opts.token) { + throw new Error('createClient requires a token') + } + const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT + const transport = createConnectTransport({ + baseUrl: endpoint, + httpVersion: '2', + interceptors: [ + (next) => (req) => { + req.header.set('Authorization', `Bearer ${opts.token}`) + // App and service tokens, along with user tokens that belong to more + // than one organization, need the `x-depot-org` header so the server + // knows which organization to act on. Without it, those requests are + // rejected with PermissionDenied. A user token bound to a single + // organization works with or without the header. + if (opts.orgID) { + req.header.set('x-depot-org', opts.orgID) + } + return next(req) + }, + ], + }) + return { + rpc: createConnectClient(SandboxService, transport), + endpoint, + } +} + +export interface CreateClientOpts { + /** Bearer token used to authenticate requests, typically your `DEPOT_TOKEN`. */ + token: string + /** API endpoint to connect to. Defaults to {@link DEFAULT_ENDPOINT}. */ + endpoint?: string + /** + * Organization the client should act on. This is required for app and + * service tokens, and for user tokens that belong to more than one + * organization. For a user token bound to a single organization it is + * ignored, since that organization is already implied. This corresponds to + * the `--org` flag on the `depot` CLI. + */ + orgID?: string +} + +/** Opaque client wrapping a Connect `Client`. */ +export interface SandboxClient { + readonly rpc: Client + readonly endpoint: string +} diff --git a/packages/sandbox/src/command.test.ts b/packages/sandbox/src/command.test.ts new file mode 100644 index 0000000..11c3921 --- /dev/null +++ b/packages/sandbox/src/command.test.ts @@ -0,0 +1,280 @@ +import {create, type MessageInitShape} from '@bufbuild/protobuf' +import {timestampFromDate} from '@bufbuild/protobuf/wkt' +import assert from 'node:assert' +import test from 'node:test' +import {_commandInternals, SandboxCommandExecution, type SandboxCommandExecutionFinished} from './command.js' +import { + SandboxCommandExecutionStatus as CommandStatusProto, + SandboxCommandExecutionEvent_FinishedSchema, + SandboxCommandExecutionEvent_StartedSchema, + SandboxCommandExecutionEvent_StderrBytesSchema, + SandboxCommandExecutionEvent_StdoutBytesSchema, + SandboxCommandExecutionEventSchema, + SandboxCommandExecutionSchema, + type SandboxCommandExecutionEvent, +} from './gen/depot/sandbox/v1/command_pb.js' + +// These tests cover the round-trip between a wire proto and a SandboxCommandExecution +// instance. The proto-to-field mapping lives in one place, and if someone adds a new +// field they need to update both fromProto and applyProto. These tests catch the case +// where the constructor populates a field but applyProto forgets to refresh it. + +const STARTED_AT = new Date('2026-05-23T10:00:00.000Z') +const FINISHED_AT = new Date('2026-05-23T10:00:05.500Z') + +function makeProto(overrides: MessageInitShape = {}) { + return create(SandboxCommandExecutionSchema, { + cmdId: 'cmd_01H9X', + sandboxId: 'sbx_01H9X', + cmd: '/bin/echo', + args: ['hello', 'world'], + cwd: '/tmp', + env: {FOO: 'bar'}, + sudo: false, + detached: false, + status: CommandStatusProto.RUNNING, + startedAt: timestampFromDate(STARTED_AT), + stdoutBytesEmitted: 0n, + stderrBytesEmitted: 0n, + ...overrides, + }) +} + +test('SandboxCommandExecution.fromProto populates readonly fields from a wire-shape proto', () => { + const cmd = ( + SandboxCommandExecution as unknown as {fromProto: (p: ReturnType) => SandboxCommandExecution} + ).fromProto(makeProto()) + assert.equal(cmd.cmdId, 'cmd_01H9X') + assert.equal(cmd.sandboxId, 'sbx_01H9X') + assert.equal(cmd.cmd, '/bin/echo') + assert.deepEqual([...cmd.args], ['hello', 'world']) + assert.equal(cmd.cwd, '/tmp') + assert.deepEqual({...cmd.env}, {FOO: 'bar'}) + assert.equal(cmd.sudo, false) + assert.equal(cmd.detached, false) + assert.equal(cmd.startedAt.toISOString(), STARTED_AT.toISOString()) + assert.equal(cmd.status, 'running') + assert.equal(cmd.exitCode, undefined) + assert.equal(cmd.finishedAt, undefined) + assert.equal(cmd.stdoutBytesEmitted, 0) + assert.equal(cmd.stderrBytesEmitted, 0) +}) + +test('SandboxCommandExecution.applyProto refreshes mutable accessors on settle', () => { + type CmdInternal = SandboxCommandExecution & {applyProto(p: ReturnType): void} + const cmd = ( + SandboxCommandExecution as unknown as {fromProto: (p: ReturnType) => SandboxCommandExecution} + ).fromProto(makeProto()) + assert.equal(cmd.status, 'running') + assert.equal(cmd.exitCode, undefined) + ;(cmd as CmdInternal).applyProto( + makeProto({ + status: CommandStatusProto.FINISHED, + exitCode: 0, + finishedAt: timestampFromDate(FINISHED_AT), + stdoutBytesEmitted: 12n, + stderrBytesEmitted: 4n, + }), + ) + + assert.equal(cmd.status, 'finished') + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.finishedAt?.toISOString(), FINISHED_AT.toISOString()) + assert.equal(cmd.stdoutBytesEmitted, 12) + assert.equal(cmd.stderrBytesEmitted, 4) +}) + +test('SandboxCommandExecutionFinished is a type-narrowed view of the same instance', () => { + type CmdInternal = SandboxCommandExecution & {applyProto(p: ReturnType): void} + const cmd = ( + SandboxCommandExecution as unknown as {fromProto: (p: ReturnType) => SandboxCommandExecution} + ).fromProto(makeProto()) + ;(cmd as CmdInternal).applyProto( + makeProto({ + status: CommandStatusProto.FINISHED, + exitCode: 1, + finishedAt: timestampFromDate(FINISHED_AT), + }), + ) + // SandboxCommandExecutionFinished is just the same instance viewed through a narrower + // type that marks exitCode and finishedAt as non-undefined. Casting to it preserves + // object identity, so callers don't have to re-check those fields after wait(). + const finished = cmd as SandboxCommandExecutionFinished + assert.strictEqual(finished, cmd) + assert.equal(finished.exitCode, 1) + assert.equal(finished.finishedAt.toISOString(), FINISHED_AT.toISOString()) +}) + +// The remaining tests cover the runCommand driver: how it ingests a stream of execution +// events and surfaces them through the accessors, logs(), output(), and wait(). + +function makeStartedEvent(cmdId: string, at: Date): SandboxCommandExecutionEvent { + return create(SandboxCommandExecutionEventSchema, { + event: { + case: 'started', + value: create(SandboxCommandExecutionEvent_StartedSchema, {cmdId, startedAt: timestampFromDate(at)}), + }, + }) +} + +function makeStdoutEvent(data: string, byteOffset: number): SandboxCommandExecutionEvent { + return create(SandboxCommandExecutionEventSchema, { + event: { + case: 'stdout', + value: create(SandboxCommandExecutionEvent_StdoutBytesSchema, { + data: new TextEncoder().encode(data), + byteOffset: BigInt(byteOffset), + timestamp: timestampFromDate(new Date()), + }), + }, + }) +} + +function makeStderrEvent(data: string, byteOffset: number): SandboxCommandExecutionEvent { + return create(SandboxCommandExecutionEventSchema, { + event: { + case: 'stderr', + value: create(SandboxCommandExecutionEvent_StderrBytesSchema, { + data: new TextEncoder().encode(data), + byteOffset: BigInt(byteOffset), + timestamp: timestampFromDate(new Date()), + }), + }, + }) +} + +function makeFinishedEvent(exitCode: number, at: Date): SandboxCommandExecutionEvent { + return create(SandboxCommandExecutionEventSchema, { + event: { + case: 'finished', + value: create(SandboxCommandExecutionEvent_FinishedSchema, {exitCode, finishedAt: timestampFromDate(at)}), + }, + }) +} + +test('runCommand driver: ingest pumps Startedβ†’stdoutβ†’Finished through to accessors and wait()', async () => { + const cmd = _commandInternals.fromStartedEvent({ + cmdId: 'cmd_01H9X', + sandboxId: 'sbx_01H9X', + cmd: '/bin/echo', + args: ['hello'], + cwd: undefined, + env: {}, + sudo: false, + detached: false, + startedAt: STARTED_AT, + }) + + _commandInternals.ingest(cmd, makeStartedEvent('cmd_01H9X', STARTED_AT)) + _commandInternals.ingest(cmd, makeStdoutEvent('hello\n', 6)) + _commandInternals.ingest(cmd, makeFinishedEvent(0, FINISHED_AT)) + _commandInternals.end(cmd) + + const finished = await cmd.wait() + assert.strictEqual(finished, cmd) + assert.equal(finished.exitCode, 0) + assert.equal(finished.finishedAt.toISOString(), FINISHED_AT.toISOString()) + assert.equal(cmd.status, 'finished') + assert.equal(cmd.stdoutBytesEmitted, 6) +}) + +test('runCommand driver: logs() yields decoded UTF-8 chunks per stream', async () => { + const cmd = _commandInternals.fromStartedEvent({ + cmdId: 'cmd_01H9Y', + sandboxId: 'sbx_01H9Y', + cmd: '/bin/sh', + args: ['-c', 'echo out; echo err 1>&2'], + cwd: undefined, + env: {}, + sudo: false, + detached: false, + startedAt: STARTED_AT, + }) + + _commandInternals.ingest(cmd, makeStartedEvent('cmd_01H9Y', STARTED_AT)) + _commandInternals.ingest(cmd, makeStdoutEvent('out\n', 4)) + _commandInternals.ingest(cmd, makeStderrEvent('err\n', 4)) + _commandInternals.ingest(cmd, makeFinishedEvent(0, FINISHED_AT)) + _commandInternals.end(cmd) + + const chunks: Array<{stream: string; data: string}> = [] + for await (const c of cmd.logs()) chunks.push({stream: c.stream, data: c.data}) + assert.deepEqual(chunks, [ + {stream: 'stdout', data: 'out\n'}, + {stream: 'stderr', data: 'err\n'}, + ]) +}) + +test('runCommand driver: output("stdout") returns concatenated stdout only', async () => { + const cmd = _commandInternals.fromStartedEvent({ + cmdId: 'cmd_01H9Z', + sandboxId: 'sbx_01H9Z', + cmd: '/bin/sh', + args: [], + cwd: undefined, + env: {}, + sudo: false, + detached: false, + startedAt: STARTED_AT, + }) + + _commandInternals.ingest(cmd, makeStartedEvent('cmd_01H9Z', STARTED_AT)) + _commandInternals.ingest(cmd, makeStdoutEvent('hello ', 6)) + _commandInternals.ingest(cmd, makeStderrEvent('boom\n', 5)) + _commandInternals.ingest(cmd, makeStdoutEvent('world\n', 12)) + _commandInternals.ingest(cmd, makeFinishedEvent(0, FINISHED_AT)) + _commandInternals.end(cmd) + + assert.equal(await cmd.output('stdout'), 'hello world\n') + assert.equal(await cmd.output('stderr'), 'boom\n') + assert.equal(await cmd.output('both'), 'hello boom\nworld\n') +}) + +test('runCommand driver: wait() throws when stream surfaces an Error event', async () => { + const cmd = _commandInternals.fromStartedEvent({ + cmdId: 'cmd_01HBAD', + sandboxId: 'sbx_01HBAD', + cmd: '/bin/false', + args: [], + cwd: undefined, + env: {}, + sudo: false, + detached: false, + startedAt: STARTED_AT, + }) + _commandInternals.ingest(cmd, makeStartedEvent('cmd_01HBAD', STARTED_AT)) + _commandInternals.ingest( + cmd, + create(SandboxCommandExecutionEventSchema, {event: {case: 'error', value: {reason: 'vm vanished'}}}), + ) + _commandInternals.end(cmd) + + await assert.rejects(() => cmd.wait(), /vm vanished/) +}) + +test('detached command: logs(), output(), and wait() are unavailable', async () => { + // A detached command runs fire-and-forget; the driver ingests Started and + // then returns without draining, so the SDK holds no output for it. All three + // output-facing accessors must reject rather than hang or return empty. + const cmd = _commandInternals.fromStartedEvent({ + cmdId: 'cmd_01HDETACH', + sandboxId: 'sbx_01HDETACH', + cmd: '/bin/sleep', + args: ['3600'], + cwd: undefined, + env: {}, + sudo: false, + detached: true, + startedAt: STARTED_AT, + }) + _commandInternals.ingest(cmd, makeStartedEvent('cmd_01HDETACH', STARTED_AT)) + + assert.equal(cmd.detached, true) + await assert.rejects(() => cmd.wait(), /detached/) + await assert.rejects(() => cmd.output(), /detached/) + await assert.rejects(async () => { + for await (const _ of cmd.logs()) { + // unreachable β€” logs() throws as soon as it is iterated + } + }, /detached/) +}) diff --git a/packages/sandbox/src/command.ts b/packages/sandbox/src/command.ts new file mode 100644 index 0000000..400ef5d --- /dev/null +++ b/packages/sandbox/src/command.ts @@ -0,0 +1,561 @@ +import {timestampDate, type Timestamp} from '@bufbuild/protobuf/wkt' +import { + SandboxCommandExecutionStatus as CommandStatusProto, + type SandboxCommandExecutionEvent, + type SandboxCommandExecution as SandboxCommandExecutionProto, +} from './gen/depot/sandbox/v1/command_pb.js' +import {BufferedEventLog} from './k-streaming.js' +import type {SandboxCommandExecutionStatus} from './types.js' + +/** + * A single decoded chunk of output yielded by {@link SandboxCommandExecution.logs}. Output + * arrives over the wire as raw bytes; the SDK decodes it as UTF-8 here before handing it + * to you. + */ +export interface SandboxCommandExecutionLogChunk { + stream: 'stdout' | 'stderr' + data: string + /** + * The running total of bytes seen on this stream, up to and including this chunk. You can + * use it to resume from where you left off when re-attaching to a command. + */ + byteOffset: number + timestamp: Date +} + +/** + * A command running in a sandbox, or one that has already finished. You get one back from + * {@link Sandbox.runCommand}. + * + * Once the command finishes, the same instance can be viewed through the narrower + * {@link SandboxCommandExecutionFinished} type, which guarantees `exitCode` and `finishedAt` + * are present. The object identity stays the same; `wait()` simply returns that narrower type. + */ +export class SandboxCommandExecution { + /** Unique identifier for this command, assigned by the server. */ + readonly cmdId: string + readonly sandboxId: string + readonly cmd: string + readonly args: ReadonlyArray + readonly cwd: string | undefined + readonly env: Readonly> + readonly sudo: boolean + readonly detached: boolean + readonly startedAt: Date + + protected _status: SandboxCommandExecutionStatus + protected _exitCode: number | undefined + protected _finishedAt: Date | undefined + protected _stdoutBytesEmitted: number + protected _stderrBytesEmitted: number + + // The event log for this command. Every `logs()` consumer reads from it, and `wait()` + // observes it to learn when the command settles. As the command runs, the background task + // started by `Sandbox.runCommand` feeds events from the server stream into this log. + // + // The log keeps a bounded replay buffer so a consumer that attaches late, or falls a little + // behind, can still catch up on recent events. A consumer that falls too far behind for the + // buffer to hold its place will receive a SlowConsumerError. + protected readonly events: BufferedEventLog + + // A promise that resolves (or rejects) when the command reaches a terminal state. This is + // kept separate from the event log on purpose: wait() waits on this promise instead of + // reading the log as an ordinary consumer. An ordinary consumer advances past each event it + // reads, which would let the replay buffer drop early output that no one has read yet β€” so a + // later output() or logs() call would only see the tail of the output rather than the whole + // thing. + protected _terminalPromise: Promise + protected _terminalResolve!: () => void + protected _terminalReject!: (err: unknown) => void + protected _terminalSettled = false + // The reason from the most recent mid-stream error event, if any. We remember it so that if + // the stream ends before a Finished event arrives, we can report that original error rather + // than a generic "stream closed without a Finished event" message. + protected _lastErrorReason: string | undefined + + protected constructor(opts: { + cmdId: string + sandboxId: string + cmd: string + args: string[] + cwd: string | undefined + env: Record + sudo: boolean + detached: boolean + startedAt: Date + status: SandboxCommandExecutionStatus + stdoutBytesEmitted: number + stderrBytesEmitted: number + }) { + this.cmdId = opts.cmdId + this.sandboxId = opts.sandboxId + this.cmd = opts.cmd + this.args = Object.freeze([...opts.args]) + this.cwd = opts.cwd + this.env = Object.freeze({...opts.env}) + this.sudo = opts.sudo + this.detached = opts.detached + this.startedAt = opts.startedAt + this._status = opts.status + this._stdoutBytesEmitted = opts.stdoutBytesEmitted + this._stderrBytesEmitted = opts.stderrBytesEmitted + // Size each buffered event by the size of its output payload, so the SDK measures the + // replay buffer the same way the server does. This keeps byte accounting consistent and + // accurate even though the SDK doesn't impose its own cap. + this.events = new BufferedEventLog({ + byteCostOf: (e) => commandEventByteCost(e), + }) + this._terminalPromise = new Promise((resolve, reject) => { + this._terminalResolve = resolve + this._terminalReject = reject + }) + } + + // Settle the terminal promise, but only the first time. Later calls do nothing, so a + // Finished event followed by a stream failure (or the reverse) won't try to settle it twice. + // When settling with an error, attach a no-op .catch so Node doesn't warn about an unhandled + // rejection if the caller never calls wait() β€” calling wait() is optional. + protected settleTerminal(err?: unknown): void { + if (this._terminalSettled) return + this._terminalSettled = true + if (err !== undefined) { + this._terminalReject(err) + this._terminalPromise.catch(() => {}) + } else { + this._terminalResolve() + } + } + + /** The command's current status. It reaches `finished`, `failed`, or `killed` once the command ends. */ + get status(): SandboxCommandExecutionStatus { + return this._status + } + + /** The command's exit code once it has finished, or undefined while it is still running. */ + get exitCode(): number | undefined { + return this._exitCode + } + + /** The time the command finished, or undefined while it is still running. */ + get finishedAt(): Date | undefined { + return this._finishedAt + } + + /** The total number of stdout bytes the server has produced so far. */ + get stdoutBytesEmitted(): number { + return this._stdoutBytesEmitted + } + + /** The total number of stderr bytes the server has produced so far. */ + get stderrBytesEmitted(): number { + return this._stderrBytesEmitted + } + + /** + * Stream the command's output as decoded UTF-8 chunks, with stdout and stderr interleaved in + * the order they arrived. + * + * You can call this more than once. Each call returns its own independent iterator over the + * same shared output, so several consumers can read the logs at once without interfering with + * one another. A consumer that attaches after the command has started will replay whatever + * output is still held in the replay buffer before catching up to live output. A consumer + * that reads too slowly may fall behind what the buffer can hold, in which case its iterator + * fails with a SlowConsumerError. + */ + async *logs(): AsyncGenerator { + if (this.detached) throw detachedUnavailable() + // Decode each stream incrementally with its own decoder so that a multi-byte UTF-8 + // character split across two chunks still decodes correctly instead of turning into + // replacement characters at the chunk boundary. Each call to logs() keeps its own decoders, + // so concurrent consumers don't share decoding state. + const stdoutDecoder = new TextDecoder('utf-8', {fatal: false}) + const stderrDecoder = new TextDecoder('utf-8', {fatal: false}) + // Track the latest byte offset for each stream separately. When we flush a trailing partial + // character at the end, it needs to carry its own stream's offset rather than whichever + // stream happened to produce the most recent chunk. Per-stream offsets are what resume and + // progress tracking rely on, so they must not be mixed. + let lastStdoutOffset = 0 + let lastStderrOffset = 0 + for await (const event of this.events.iterate()) { + const chunk = decodeEvent(event, stdoutDecoder, stderrDecoder) + if (chunk) { + if (chunk.stream === 'stdout') lastStdoutOffset = chunk.byteOffset + else lastStderrOffset = chunk.byteOffset + yield chunk + } + } + // Once the stream ends, flush each decoder so any trailing partial UTF-8 character is + // emitted as a replacement character rather than silently dropped. + const stdoutTail = stdoutDecoder.decode() + const stderrTail = stderrDecoder.decode() + if (stdoutTail.length > 0) { + yield {stream: 'stdout', data: stdoutTail, byteOffset: lastStdoutOffset, timestamp: new Date()} + } + if (stderrTail.length > 0) { + yield {stream: 'stderr', data: stderrTail, byteOffset: lastStderrOffset, timestamp: new Date()} + } + } + + /** + * Wait for the command to finish. The returned value is typed as + * {@link SandboxCommandExecutionFinished}, so once it resolves you can read `exitCode` and + * `finishedAt` without checking for undefined. + */ + async wait(): Promise { + if (this.detached) throw detachedUnavailable() + // Wait on the terminal promise instead of reading the event log. Reading the log here would + // advance a consumer cursor, which could let the replay buffer drop early output during a + // quiet period β€” breaking the common pattern of `await cmd.wait()` followed by + // `await cmd.output()`. + await this._terminalPromise + // Only narrow the type once both fields are actually present. The narrowed type declares + // them as non-optional. A stream-level failure rejects the terminal promise, so we never + // reach this point in that case. + if (this._exitCode !== undefined && this._finishedAt !== undefined) { + return this as SandboxCommandExecutionFinished + } + throw new Error(`SandboxCommandExecution ${this.cmdId} stream closed without Finished event`) + } + + /** + * Collect the command's output, waiting until the command finishes. Pass `'both'` (the + * default) to get stdout and stderr interleaved in arrival order, or `'stdout'` / `'stderr'` + * to get just one stream. + * + * This reads the output and watches for the command to finish using a single iterator, + * rather than calling wait() first and replaying afterward. Replaying after wait() could miss + * early output that the replay buffer had since dropped during a quiet period. Reading it all + * in one pass keeps the iterator at the start of the output until output() has actually + * consumed each chunk, so the only thing that can ever truncate the result is the 16 MiB + * size limit. + */ + async output(which: 'both' | 'stdout' | 'stderr' = 'both'): Promise { + if (this.detached) throw detachedUnavailable() + const stdoutDecoder = new TextDecoder('utf-8', {fatal: false}) + const stderrDecoder = new TextDecoder('utf-8', {fatal: false}) + let out = '' + for await (const event of this.events.iterate({fromOffset: 0})) { + const chunk = decodeEvent(event, stdoutDecoder, stderrDecoder) + if (chunk && (which === 'both' || which === chunk.stream)) { + out += chunk.data + } + // An error event signals partial degradation mid-stream, not the end: the stream can + // still recover and go on to deliver a Finished event. So don't stop here β€” keep + // collecting output. If the stream really does close without finishing, the failure + // surfaces as an exception from this loop instead. + if (event.event.case === 'finished') break + } + // Flush the decoders so any trailing partial UTF-8 character is emitted as a replacement + // character rather than silently dropped. + if (which === 'both' || which === 'stdout') out += stdoutDecoder.decode() + if (which === 'both' || which === 'stderr') out += stderrDecoder.decode() + return out + } + + /** Shorthand for `output('stdout')`. */ + stdout(): Promise { + return this.output('stdout') + } + + /** Shorthand for `output('stderr')`. */ + stderr(): Promise { + return this.output('stderr') + } + + /** + * Build a {@link SandboxCommandExecution} from its wire representation. This is the single + * place that maps proto fields onto instance fields, mirroring how `Sandbox.fromProto` + * works. + * + * @internal + */ + protected static fromProto(proto: SandboxCommandExecutionProto): SandboxCommandExecution { + const instance = new SandboxCommandExecution({ + cmdId: proto.cmdId, + sandboxId: proto.sandboxId, + cmd: proto.cmd, + args: proto.args, + cwd: proto.cwd, + env: proto.env, + sudo: proto.sudo, + detached: proto.detached, + startedAt: requireTimestamp(proto.startedAt, 'SandboxCommandExecution.started_at'), + status: commandStatusFromProto(proto.status) ?? 'running', + stdoutBytesEmitted: Number(proto.stdoutBytesEmitted), + stderrBytesEmitted: Number(proto.stderrBytesEmitted), + }) + instance.applyProto(proto) + // This builds a point-in-time snapshot of a command, with no live stream feeding new events + // into its buffer. Close the buffer right away so that output() and logs() iterators + // terminate normally instead of waiting forever for events that will never arrive. + instance.events.end() + // Settle the terminal promise based on the snapshot's status: + // - finished: the command exited cleanly, so exit_code and finished_at are present. + // Resolve to the narrowed finished type. If either field is missing, the snapshot is + // malformed, so reject and say so. + // - failed / killed: terminal, but the process may never have produced an exit code (the + // wire contract only guarantees one for a finished command). wait() rejects for these + // either way, so reject with the status rather than calling a real failure malformed. + // - running / pending: not a terminal state. A static snapshot can never go on to finish, + // so there is nothing to wait for β€” reject. + if (instance._status === 'finished') { + if (instance._exitCode !== undefined && instance._finishedAt !== undefined) { + instance.settleTerminal() + } else { + instance.settleTerminal( + new Error(`SandboxCommandExecution ${instance.cmdId} snapshot is FINISHED but missing exit_code/finished_at`), + ) + } + } else if (instance._status === 'failed' || instance._status === 'killed') { + instance.settleTerminal(new Error(`SandboxCommandExecution ${instance.cmdId} ${instance._status}`)) + } else { + instance.settleTerminal( + new Error( + `SandboxCommandExecution ${instance.cmdId} snapshot is not in a terminal state (status=${instance._status})`, + ), + ) + } + return instance + } + + /** + * Construct an instance from the initial Started event, which carries only the command ID and + * start time, together with the caller's original options β€” the case where no full command + * proto is available yet. Same-package callers reach this through the {@link _commandInternals} + * helper. + * + * @internal + */ + protected static _fromStartedEvent(opts: { + cmdId: string + sandboxId: string + cmd: string + args: string[] + cwd: string | undefined + env: Record + sudo: boolean + detached: boolean + startedAt: Date + }): SandboxCommandExecution { + return new SandboxCommandExecution({ + cmdId: opts.cmdId, + sandboxId: opts.sandboxId, + cmd: opts.cmd, + args: opts.args, + cwd: opts.cwd, + env: opts.env, + sudo: opts.sudo, + detached: opts.detached, + startedAt: opts.startedAt, + status: 'running', + stdoutBytesEmitted: 0, + stderrBytesEmitted: 0, + }) + } + + /** + * Update this instance's mutable fields from a fresh proto returned by the server. The + * immutable identity fields are set once by the constructor; only the state-derived fields + * (status, exit code, byte counts, and so on) are refreshed here. + * + * @internal + */ + protected applyProto(proto: SandboxCommandExecutionProto): void { + this._status = commandStatusFromProto(proto.status) ?? this._status + this._exitCode = proto.exitCode + this._finishedAt = proto.finishedAt !== undefined ? timestampDate(proto.finishedAt) : undefined + this._stdoutBytesEmitted = Number(proto.stdoutBytesEmitted) + this._stderrBytesEmitted = Number(proto.stderrBytesEmitted) + } + + /** + * Apply a single event to this command: update the instance's mutable fields to reflect it, + * then append it to the event log. The events arrive in the sequence Started, then any number + * of Stdout and Stderr events, then Finished. Called by the background task that + * `Sandbox.runCommand` starts to consume the server's response stream. + * + * @internal + */ + protected _ingestEvent(event: SandboxCommandExecutionEvent): void { + const e = event.event + switch (e.case) { + case 'started': + this._status = 'running' + break + case 'stdout': + this._stdoutBytesEmitted = Number(e.value.byteOffset) + break + case 'stderr': + this._stderrBytesEmitted = Number(e.value.byteOffset) + break + case 'finished': + this._exitCode = e.value.exitCode + this._finishedAt = e.value.finishedAt !== undefined ? timestampDate(e.value.finishedAt) : new Date() + // The exit code is separate from the status. A Finished event means the process exited + // and produced an exit code, whatever that code is β€” even a non-zero one still counts as + // finished. The 'failed' status is reserved for cases where the process never produced + // an exit code at all, such as the host aborting or the stream dying; those arrive + // through the 'error' case below or through _failStream(). + this._status = 'finished' + this.settleTerminal() + break + case 'error': + // An error event signals partial degradation mid-stream; it is not the terminal marker. + // The stream can still deliver more output and eventually a Finished event. Leave the + // status unchanged so that code polling status() doesn't see the terminal 'failed' value + // for a command that ends up succeeding. Just remember the reason, so _endStream can use + // it if the stream ends without a Finished event. The command settles only on Finished, + // _endStream, or _failStream. + this._lastErrorReason = e.value.reason + break + case 'evicted': + case undefined: + break + } + this.events.append(event) + } + + /** + * Close the event log so any iterators that are still draining their queues can complete. + * Called by the background task when the server stream closes normally. + * + * @internal + */ + protected _endStream(): void { + this.events.end() + // If a Finished event already settled the command, this does nothing. Otherwise the stream + // closed cleanly but never delivered a Finished event, which counts as a failure. Use the + // last mid-stream error reason if one was seen, since it carries the more useful message, + // and otherwise fall back to a generic message. + const message = + this._lastErrorReason !== undefined + ? `SandboxCommandExecution ${this.cmdId} failed: ${this._lastErrorReason}` + : `SandboxCommandExecution ${this.cmdId} stream closed without Finished event` + this.settleTerminal(new Error(message)) + } + + /** + * Fail the event log so its iterators reject. Called by the background task when the server + * stream errors. + * + * @internal + */ + protected _failStream(err: unknown): void { + this._status = 'failed' + this.events.error(err) + this.settleTerminal(err) + } +} + +/** + * A typed view of a {@link SandboxCommandExecution} that has finished. It is the same runtime + * object that `wait()` was called on β€” identity is preserved β€” but its type narrows `exitCode` + * and `finishedAt` to be non-undefined, so you don't have to check for undefined after awaiting. + * + * This is deliberately an intersection type rather than a subclass. A subclass would only narrow + * at runtime if `wait()` built a new instance, which would break identity with any `logs()` + * consumers still holding the original reference. The intersection lets the return type of + * `wait()` narrow at the type level while `this` remains the one and only instance. + */ +export type SandboxCommandExecutionFinished = SandboxCommandExecution & { + readonly exitCode: number + readonly finishedAt: Date +} + +// A detached command runs fire-and-forget: the server never streams its output +// back and nothing is stored, so the SDK has no events to hand out. logs(), +// output(), and wait() raise this rather than hang forever or return empty. +function detachedUnavailable(): Error { + return new Error( + 'this command was run detached (beta): its output is not retained and cannot be retrieved yet, ' + + 'so logs(), output(), and wait() are unavailable', + ) +} + +function commandStatusFromProto(status: CommandStatusProto): SandboxCommandExecutionStatus | undefined { + switch (status) { + case CommandStatusProto.PENDING: + return 'pending' + case CommandStatusProto.RUNNING: + return 'running' + case CommandStatusProto.FINISHED: + return 'finished' + case CommandStatusProto.FAILED: + return 'failed' + case CommandStatusProto.KILLED: + return 'killed' + case CommandStatusProto.UNSPECIFIED: + return undefined + } +} + +function requireTimestamp(ts: Timestamp | undefined, field: string): Date { + if (ts === undefined) { + throw new Error(`SandboxCommandExecution proto missing required ${field}`) + } + return timestampDate(ts) +} + +function commandEventByteCost(event: SandboxCommandExecutionEvent): number { + const e = event.event + if (e.case === 'stdout' || e.case === 'stderr') return e.value.data.byteLength + return 0 +} + +function decodeEvent( + event: SandboxCommandExecutionEvent, + stdoutDecoder: InstanceType, + stderrDecoder: InstanceType, +): SandboxCommandExecutionLogChunk | null { + const e = event.event + if (e.case === 'stdout') { + return { + stream: 'stdout', + data: stdoutDecoder.decode(e.value.data, {stream: true}), + byteOffset: Number(e.value.byteOffset), + timestamp: e.value.timestamp !== undefined ? timestampDate(e.value.timestamp) : new Date(), + } + } + if (e.case === 'stderr') { + return { + stream: 'stderr', + data: stderrDecoder.decode(e.value.data, {stream: true}), + byteOffset: Number(e.value.byteOffset), + timestamp: e.value.timestamp !== undefined ? timestampDate(e.value.timestamp) : new Date(), + } + } + return null +} + +// A same-package entry point that lets `Sandbox.runCommand` create a SandboxCommandExecution +// from a Started event and feed events into its log. TypeScript only enforces `protected` +// within the class and its subclasses, so these helpers cast to a structural shape in order to +// call the underscore-prefixed methods. The leading underscore and the fact that this is not +// exported from index.ts keep it off the public API surface. It exists only for +// the runCommand machinery, not as a stable, supported API. +export const _commandInternals = { + fromStartedEvent(opts: { + cmdId: string + sandboxId: string + cmd: string + args: string[] + cwd: string | undefined + env: Record + sudo: boolean + detached: boolean + startedAt: Date + }): SandboxCommandExecution { + return ( + SandboxCommandExecution as unknown as {_fromStartedEvent(o: typeof opts): SandboxCommandExecution} + )._fromStartedEvent(opts) + }, + ingest(cmd: SandboxCommandExecution, event: SandboxCommandExecutionEvent): void { + ;(cmd as unknown as {_ingestEvent(e: SandboxCommandExecutionEvent): void})._ingestEvent(event) + }, + end(cmd: SandboxCommandExecution): void { + ;(cmd as unknown as {_endStream(): void})._endStream() + }, + fail(cmd: SandboxCommandExecution, err: unknown): void { + ;(cmd as unknown as {_failStream(err: unknown): void})._failStream(err) + }, +} diff --git a/packages/sandbox/src/errors.ts b/packages/sandbox/src/errors.ts new file mode 100644 index 0000000..f9dc143 --- /dev/null +++ b/packages/sandbox/src/errors.ts @@ -0,0 +1,142 @@ +// Error types surfaced by @depot/sandbox. +// +// Surfaced from command-output consumer iteration and the file system surface. +// Wider sets of SDK errors can be added as new failure modes are exposed. + +import {ConnectError} from '@connectrpc/connect' +import { + FileSystemErrorCode as FileSystemErrorCodeProto, + FileSystemErrorDetailSchema, +} from './gen/depot/sandbox/v1/filesystem_pb.js' + +/** + * Thrown into a `SandboxCommandExecution.logs()` iterator when its bounded queue + * overflows because the producer (the underlying RPC stream) outran the + * consumer's drain rate. Other consumers on the same command (and the + * underlying stream) are unaffected. + */ +export class SlowConsumerError extends Error { + override readonly name = 'SlowConsumerError' + constructor(message = 'event consumer queue overflow') { + super(message) + } +} + +// File system errors. + +/** + * The Node-style fs error codes the SDK exposes, as a string union. These match + * the codes Node throws on a real `node:fs/promises` failure, so existing + * `if (err.code === 'ENOENT')` checks keep working against a sandbox file + * system. `OTHER` is the catch-all the server uses when it can't classify a + * failure into a specific code. + */ +export type FileSystemErrorCode = + | 'EPERM' + | 'ENOENT' + | 'EINTR' + | 'EIO' + | 'EBADF' + | 'ENOMEM' + | 'EACCES' + | 'EBUSY' + | 'EEXIST' + | 'EXDEV' + | 'ENOTDIR' + | 'EISDIR' + | 'EINVAL' + | 'ENFILE' + | 'EMFILE' + | 'ETXTBSY' + | 'EFBIG' + | 'ENOSPC' + | 'EROFS' + | 'EMLINK' + | 'ENAMETOOLONG' + | 'ENOTEMPTY' + | 'ELOOP' + | 'EOPNOTSUPP' + | 'EDQUOT' + | 'OTHER' + +// The proto enum value of each code IS its Linux errno number (OTHER is an +// off-band sentinel), so the generated enum is the only mapping we need β€” read +// in both directions, with no hand-maintained code<->number or code<->errno +// table to drift from the wire. +const PROTO_PREFIX = 'FILESYSTEM_ERROR_CODE_' + +// Wire value (an errno number, or the OTHER sentinel) -> friendly string code, +// taken straight from the generated enum's own reverse lookup. UNSPECIFIED, the +// OTHER sentinel, and any value this build doesn't recognize (a newer peer +// sending a code we don't know) all read as OTHER, so the caller still gets a +// usable error. +function codeFromProto(value: FileSystemErrorCodeProto): FileSystemErrorCode { + const name = (FileSystemErrorCodeProto as Record)[value] + if (name === undefined || value === FileSystemErrorCodeProto.FILESYSTEM_ERROR_CODE_UNSPECIFIED) return 'OTHER' + const stripped = name.slice(PROTO_PREFIX.length) + return stripped === 'OTHER' ? 'OTHER' : (stripped as FileSystemErrorCode) +} + +// Negated Linux errno for a code, for Node-shape parity. The code's proto value +// is its errno, so this is just the negation; OTHER (and anything without a +// real errno) reads as -1, matching libuv's "no match". This is the sandbox's +// errno β€” the file system runs on Linux β€” and `code` is the portable key. +function errnoForCode(code: FileSystemErrorCode): number { + if (code === 'OTHER') return -1 + const value = (FileSystemErrorCodeProto as unknown as Record)[PROTO_PREFIX + code] + return value === undefined || value <= 0 ? -1 : -value +} + +/** + * A Node-shape file system error, thrown by every `sandbox.fs.*` method on + * failure. It carries the same `code`/`syscall`/`path`/`errno` fields Node + * attaches to its own fs errors, so code written against `node:fs/promises` + * can branch on `err.code` without caring that the operation crossed the wire. + */ +export class FileSystemError extends Error { + /** Node-style error code, for example `'ENOENT'`. */ + readonly code: FileSystemErrorCode + /** The operation that failed, for example `'stat'` or `'open'`. */ + readonly syscall: string + /** The path the operation was attempted on. */ + readonly path: string + /** Negated Linux errno for the code, for Node-shape parity. */ + readonly errno: number + + constructor(opts: {code: FileSystemErrorCode; syscall: string; path: string; message?: string}) { + super(opts.message ?? `${opts.code}: ${opts.syscall} '${opts.path}'`) + this.name = 'FileSystemError' + this.code = opts.code + this.syscall = opts.syscall + this.path = opts.path + this.errno = errnoForCode(opts.code) + } +} + +/** + * Turn a thrown RPC error into a `FileSystemError`. The server attaches a + * `FileSystemErrorDetail` to the Connect error; this reads it back with + * `findDetails` and rebuilds the Node-shape error. When no detail is attached + * (a transport failure, or a server that didn't classify the error), it falls + * back to an `OTHER` error carrying the supplied syscall and path so the caller + * still gets a `FileSystemError` rather than a raw `ConnectError`. + * + * @internal Used by the FileSystem class to remap every RPC rejection. + */ +export function toFileSystemError(err: unknown, syscall: string, path: string): FileSystemError { + if (err instanceof ConnectError) { + const details = err.findDetails(FileSystemErrorDetailSchema) + const detail = details[0] + if (detail) { + return new FileSystemError({ + code: codeFromProto(detail.code), + syscall: detail.syscall || syscall, + path: detail.path || path, + message: detail.message, + }) + } + return new FileSystemError({code: 'OTHER', syscall, path, message: err.rawMessage}) + } + const message = err instanceof Error ? err.message : String(err) + return new FileSystemError({code: 'OTHER', syscall, path, message}) +} diff --git a/packages/sandbox/src/filesystem.test.ts b/packages/sandbox/src/filesystem.test.ts new file mode 100644 index 0000000..325050a --- /dev/null +++ b/packages/sandbox/src/filesystem.test.ts @@ -0,0 +1,356 @@ +import {create} from '@bufbuild/protobuf' +import {Code, ConnectError} from '@connectrpc/connect' +import assert from 'node:assert' +import test from 'node:test' +import type {SandboxClient} from './client.js' +import {FileSystemError} from './errors.js' +import {DirEntry, FileSystem, StatResult} from './filesystem.js' +import { + FileChunkSchema, + FileSystemErrorCode, + FileSystemErrorDetailSchema, + FileType, + ReadDirResponseSchema, + StatResponseSchema, + type WriteFileRequest, + WriteFileResponseSchema, +} from './gen/depot/sandbox/v1/filesystem_pb.js' + +// A fake SandboxClient whose rpc methods record the last request they received +// and either return a scripted response or throw a scripted error. Only the +// methods the FileSystem class touches are stubbed; everything else throws if +// called, so a test that hits an unexpected method fails loudly. +function fakeClient(overrides: Partial unknown>>): { + client: SandboxClient + lastRequest: () => unknown +} { + let last: unknown + const handler = (name: string) => { + const fn = overrides[name] + if (!fn) throw new Error(`unexpected RPC call: ${name}`) + return (req: unknown) => { + last = req + return fn(req) + } + } + const rpc = new Proxy( + {}, + { + get(_target, prop: string) { + return handler(prop) + }, + }, + ) + return { + client: {rpc, endpoint: 'http://test'} as unknown as SandboxClient, + lastRequest: () => last, + } +} + +// Build a ConnectError carrying a FileSystemErrorDetail, as the server sends. +function connectErrorWithDetail( + code: FileSystemErrorCode, + syscall: string, + path: string, + message?: string, +): ConnectError { + const detail = create(FileSystemErrorDetailSchema, {code, syscall, path, message}) + return new ConnectError(message ?? 'failure', Code.FailedPrecondition, undefined, [ + {desc: FileSystemErrorDetailSchema, value: detail}, + ]) +} + +test('mkdir sends the request against the bound sandbox', async () => { + const recording = fakeClient({mkdir: () => ({})}) + const fs = new FileSystem({client: recording.client, sandboxId: 'sbx_1'}) + await fs.mkdir('/work', {recursive: true, mode: 0o755}) + assert.deepEqual(recording.lastRequest(), { + sandbox: {selector: {case: 'id', value: 'sbx_1'}}, + path: '/work', + recursive: true, + mode: 0o755, + }) +}) + +test('stat maps the response into a StatResult with isFile/isDirectory', async () => { + const {client} = fakeClient({ + stat: () => + create(StatResponseSchema, { + path: '/f', + size: 2048n, + mode: 0o644, + type: FileType.FILE, + uname: 'alice', + gname: 'staff', + mtimeUnixSeconds: 1700000000n, + }), + }) + const fs = new FileSystem({client, sandboxId: 'sbx_1'}) + const stat = await fs.stat('/f') + assert.ok(stat instanceof StatResult) + assert.equal(stat.size, 2048) + assert.equal(stat.mode, 0o644) + assert.equal(stat.uname, 'alice') + assert.equal(stat.mtime.getTime(), 1700000000 * 1000) + assert.equal(stat.isFile(), true) + assert.equal(stat.isDirectory(), false) +}) + +test('readdir returns names by default and DirEntry[] with withFileTypes', async () => { + const response = create(ReadDirResponseSchema, { + entries: [ + {name: 'a.txt', type: FileType.FILE}, + {name: 'sub', type: FileType.DIRECTORY}, + ], + }) + const fs = new FileSystem({client: fakeClient({readDir: () => response}).client, sandboxId: 'sbx_1'}) + const names = await fs.readdir('/d') + assert.deepEqual(names, ['a.txt', 'sub']) + + const fs2 = new FileSystem({client: fakeClient({readDir: () => response}).client, sandboxId: 'sbx_1'}) + const entries = await fs2.readdir('/d', {withFileTypes: true}) + assert.ok((entries[0] as DirEntry) instanceof DirEntry) + assert.equal((entries[1] as DirEntry).isDirectory(), true) +}) + +test('readFile concatenates streamed chunks and decodes with an encoding', async () => { + async function* chunks() { + yield create(FileChunkSchema, {data: new TextEncoder().encode('hello ')}) + yield create(FileChunkSchema, {data: new TextEncoder().encode('world')}) + yield create(FileChunkSchema, {data: new Uint8Array(0), eof: true}) + } + const fs = new FileSystem({client: fakeClient({readFile: () => chunks()}).client, sandboxId: 'sbx_1'}) + const text = await fs.readFile('/f', {encoding: 'utf-8'}) + assert.equal(text, 'hello world') +}) + +test('writeFile streams an init message then the data', async () => { + const drained: WriteFileRequest[] = [] + const recording = fakeClient({ + writeFile: async (req) => { + // Drain the request stream so the SDK's generator runs and we can inspect it. + for await (const m of req as AsyncIterable) drained.push(m) + return create(WriteFileResponseSchema, {bytesWritten: BigInt(drained.length)}) + }, + }) + const fs = new FileSystem({client: recording.client, sandboxId: 'sbx_1'}) + await fs.writeFile('/f', 'hi', {recursive: true, mode: 0o600}) + + // Exactly two messages: the init naming the file and its options, then the bytes. + assert.equal(drained.length, 2) + const init = drained[0]?.input + assert.equal(init?.case, 'init') + if (init?.case === 'init') { + assert.equal(init.value.path, '/f') + assert.equal(init.value.mode, 0o600) + assert.equal(init.value.append, false) + assert.equal(init.value.createDirectories, true) + } + const data = drained[1]?.input + assert.equal(data?.case, 'data') + if (data?.case === 'data') { + assert.equal(new TextDecoder().decode(data.value), 'hi') + } +}) + +test('writeFile splits a large payload into frames under the gRPC message ceiling', async () => { + // A payload bigger than one gRPC message must arrive as several data frames, + // none larger than 4 MiB, behind a single init β€” so a gRPC client of this + // same stream wouldn't reject an oversized message. + const GRPC_MAX = 1024 * 1024 * 4 + const drained: WriteFileRequest[] = [] + const recording = fakeClient({ + writeFile: async (req) => { + for await (const m of req as AsyncIterable) drained.push(m) + return create(WriteFileResponseSchema, {bytesWritten: 0n}) + }, + }) + const fs = new FileSystem({client: recording.client, sandboxId: 'sbx_1'}) + const size = GRPC_MAX * 2 + 99 + await fs.writeFile('/big', new Uint8Array(size)) + + assert.equal(drained.filter((m) => m.input.case === 'init').length, 1) + const dataFrames = drained.filter((m) => m.input.case === 'data') + assert.ok(dataFrames.length > 1, 'expected the payload to be split across multiple frames') + let total = 0 + for (const m of dataFrames) { + if (m.input.case === 'data') { + assert.ok(m.input.value.byteLength <= GRPC_MAX, 'no frame may exceed the gRPC ceiling') + total += m.input.value.byteLength + } + } + assert.equal(total, size) +}) + +test('truncate rejects a negative size with EINVAL before hitting the wire', async () => { + let called = false + const fs = new FileSystem({ + client: fakeClient({ + truncate: () => { + called = true + return {} + }, + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => fs.truncate('/f', -1), + (err: unknown) => err instanceof FileSystemError && err.code === 'EINVAL', + ) + assert.equal(called, false) +}) + +test('a ConnectError with a detail becomes a Node-shape FileSystemError', async () => { + const fs = new FileSystem({ + client: fakeClient({ + stat: () => { + throw connectErrorWithDetail(FileSystemErrorCode.FILESYSTEM_ERROR_CODE_ENOENT, 'stat', '/missing') + }, + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => fs.stat('/missing'), + (err: unknown) => { + assert.ok(err instanceof FileSystemError) + assert.equal(err.code, 'ENOENT') + assert.equal(err.syscall, 'stat') + assert.equal(err.path, '/missing') + assert.equal(err.errno, -2) + return true + }, + ) +}) + +test('code and errno are derived from the enum value (no hand-maintained table)', async () => { + // ENOSPC's enum value is its Linux errno (28), so the negated errno falls out + // for free β€” proving the derivation works for codes beyond the original set. + const full = new FileSystem({ + client: fakeClient({ + stat: () => { + throw connectErrorWithDetail(FileSystemErrorCode.FILESYSTEM_ERROR_CODE_ENOSPC, 'write', '/disk') + }, + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => full.stat('/disk'), + (err: unknown) => err instanceof FileSystemError && err.code === 'ENOSPC' && err.errno === -28, + ) + + // OTHER is the off-band sentinel: no real errno, so it reads as -1. + const other = new FileSystem({ + client: fakeClient({ + stat: () => { + throw connectErrorWithDetail(FileSystemErrorCode.FILESYSTEM_ERROR_CODE_OTHER, 'stat', '/x') + }, + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => other.stat('/x'), + (err: unknown) => err instanceof FileSystemError && err.code === 'OTHER' && err.errno === -1, + ) +}) + +test('a ConnectError without a detail still yields an OTHER FileSystemError', async () => { + const fs = new FileSystem({ + client: fakeClient({ + mkdir: () => { + throw new ConnectError('transport blew up', Code.Unavailable) + }, + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => fs.mkdir('/x'), + (err: unknown) => err instanceof FileSystemError && err.code === 'OTHER' && err.syscall === 'mkdir', + ) +}) + +test('exists returns false on ENOENT and true otherwise', async () => { + const missing = new FileSystem({ + client: fakeClient({ + stat: () => { + throw connectErrorWithDetail(FileSystemErrorCode.FILESYSTEM_ERROR_CODE_ENOENT, 'stat', '/nope') + }, + }).client, + sandboxId: 'sbx_1', + }) + assert.equal(await missing.exists('/nope'), false) + + const present = new FileSystem({ + client: fakeClient({stat: () => create(StatResponseSchema, {path: '/yes', type: FileType.FILE})}).client, + sandboxId: 'sbx_1', + }) + assert.equal(await present.exists('/yes'), true) +}) + +test('exists rethrows a non-ENOENT error rather than reporting absence', async () => { + const fs = new FileSystem({ + client: fakeClient({ + stat: () => { + throw connectErrorWithDetail(FileSystemErrorCode.FILESYSTEM_ERROR_CODE_EACCES, 'stat', '/secret') + }, + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => fs.exists('/secret'), + (err: unknown) => err instanceof FileSystemError && err.code === 'EACCES', + ) +}) + +test('realpath throws ELOOP after exhausting the symlink hop limit', async () => { + // Every lstat reports a symlink and every readlink points back into the + // chain, so the walk never resolves and must bail with ELOOP, not OTHER. + const fs = new FileSystem({ + client: fakeClient({ + stat: () => create(StatResponseSchema, {path: '/loop', type: FileType.SYMLINK}), + readlink: () => ({target: '/loop'}), + }).client, + sandboxId: 'sbx_1', + }) + await assert.rejects( + () => fs.realpath('/loop'), + (err: unknown) => err instanceof FileSystemError && err.code === 'ELOOP', + ) +}) + +test('chmod accepts an octal string and converts it', async () => { + const recording = fakeClient({chmod: () => ({})}) + const fs = new FileSystem({client: recording.client, sandboxId: 'sbx_1'}) + await fs.chmod('/f', '755') + assert.deepEqual(recording.lastRequest(), { + sandbox: {selector: {case: 'id', value: 'sbx_1'}}, + path: '/f', + mode: 0o755, + }) +}) + +test('chmod rejects an invalid octal string with EINVAL and never hits the wire', async () => { + let called = false + const fs = new FileSystem({ + client: fakeClient({ + chmod: () => { + called = true + return {} + }, + }).client, + sandboxId: 'sbx_1', + }) + // '9' is not an octal digit, so parseInt('999', 8) is NaN. + await assert.rejects( + () => fs.chmod('/f', '999'), + (err: unknown) => err instanceof FileSystemError && err.code === 'EINVAL', + ) + assert.equal(called, false) +}) + +test('chmod rejects an out-of-range numeric mode with EINVAL', async () => { + const fs = new FileSystem({client: fakeClient({chmod: () => ({})}).client, sandboxId: 'sbx_1'}) + await assert.rejects( + () => fs.chmod('/f', 0o10000), + (err: unknown) => err instanceof FileSystemError && err.code === 'EINVAL', + ) +}) diff --git a/packages/sandbox/src/filesystem.ts b/packages/sandbox/src/filesystem.ts new file mode 100644 index 0000000..1ffd463 --- /dev/null +++ b/packages/sandbox/src/filesystem.ts @@ -0,0 +1,528 @@ +// The per-sandbox FileSystem surface. +// +// Methods mirror `node:fs/promises` so callers can drop the SDK in alongside +// existing fs code with little friction. Every method calls one SandboxService +// method against the bound sandbox and remaps a failure into a Node-shape +// FileSystemError (see errors.ts). A few methods are composed on the client +// from the primitives the server exposes β€” exists (stat + catch ENOENT), +// realpath (a readlink walk), mkdtemp (mkdir with a random suffix), appendFile +// (writeFile with append) β€” because the server intentionally doesn't ship a +// dedicated method for them. +// +// A FileSystem is bound to one sandbox and one client at construction; get it +// via `sandbox.fs()`. + +import {create} from '@bufbuild/protobuf' +import type {SandboxClient} from './client.js' +import {FileSystemError, toFileSystemError} from './errors.js' +import {FileType, WriteFileRequestSchema} from './gen/depot/sandbox/v1/filesystem_pb.js' + +// gRPC's default max message size is 4 MiB. The Connect HTTP transport the SDK +// uses doesn't impose that ceiling, but a gRPC client of this same WriteFile +// stream would, so split the payload into frames a little under 4 MiB β€” leaving +// room for the proto envelope around the bytes β€” rather than sending it as a +// single message a gRPC peer could reject. +const WRITE_CHUNK_BYTES = 1024 * 1024 * 4 - 64 * 1024 + +// Reject a permission mode that isn't a whole number of mode bits before it +// crosses the wire. An invalid octal string (chmod accepts strings) parses to +// NaN, and the server's `& 0o7777` would mask that to 0 β€” silently applying +// mode 000 instead of failing. Throw EINVAL up front, the way Node rejects an +// out-of-range mode, so the caller sees a clear error instead of a clobbered +// file. +function assertValidMode(mode: number, syscall: string, path: string): void { + if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) { + throw new FileSystemError({code: 'EINVAL', syscall, path, message: `${syscall} '${path}': invalid mode`}) + } +} + +/** The SDK-facing file type, mapped from the proto FileType enum. */ +export type FileTypeName = 'file' | 'directory' | 'symlink' | 'block' | 'char' | 'fifo' | 'socket' | 'unknown' + +function fileTypeName(type: FileType): FileTypeName { + switch (type) { + case FileType.FILE: + return 'file' + case FileType.DIRECTORY: + return 'directory' + case FileType.SYMLINK: + return 'symlink' + case FileType.BLOCK_DEVICE: + return 'block' + case FileType.CHARACTER_DEVICE: + return 'char' + case FileType.FIFO: + return 'fifo' + case FileType.SOCKET: + return 'socket' + case FileType.UNSPECIFIED: + default: + return 'unknown' + } +} + +/** + * The result of {@link FileSystem.stat}. Mirrors enough of a `node:fs/promises` + * Stats object that callers can call `.isDirectory()`/`.isFile()` and read + * size, mode, owner, and mtime without caring the value crossed the wire. + */ +export class StatResult { + readonly path: string + readonly size: number + /** POSIX permission bits as an octal integer (for example `0o644`). */ + readonly mode: number + readonly uname: string + readonly gname: string + readonly mtime: Date + readonly type: FileTypeName + + constructor(opts: { + path: string + size: number + mode: number + uname: string + gname: string + mtime: Date + type: FileTypeName + }) { + this.path = opts.path + this.size = opts.size + this.mode = opts.mode + this.uname = opts.uname + this.gname = opts.gname + this.mtime = opts.mtime + this.type = opts.type + } + + isFile(): boolean { + return this.type === 'file' + } + isDirectory(): boolean { + return this.type === 'directory' + } + /** True only when the value came from {@link FileSystem.lstat}. */ + isSymbolicLink(): boolean { + return this.type === 'symlink' + } + isBlockDevice(): boolean { + return this.type === 'block' + } + isCharacterDevice(): boolean { + return this.type === 'char' + } + isFIFO(): boolean { + return this.type === 'fifo' + } + isSocket(): boolean { + return this.type === 'socket' + } +} + +/** A directory entry returned by {@link FileSystem.readdir} with `withFileTypes`. */ +export class DirEntry { + readonly name: string + readonly type: FileTypeName + constructor(opts: {name: string; type: FileTypeName}) { + this.name = opts.name + this.type = opts.type + } + isFile(): boolean { + return this.type === 'file' + } + isDirectory(): boolean { + return this.type === 'directory' + } + isSymbolicLink(): boolean { + return this.type === 'symlink' + } +} + +export interface ReadFileOpts { + /** Decode the bytes as a string under this encoding. Omit to get a Buffer. */ + encoding?: BufferEncoding +} + +export interface WriteFileOpts { + /** POSIX mode applied if the file is created. */ + mode?: number + /** Create any missing parent directories before writing. */ + recursive?: boolean +} + +export interface MkdirOpts { + recursive?: boolean + mode?: number +} + +export interface RmOpts { + recursive?: boolean + force?: boolean +} + +export interface ReaddirOpts { + withFileTypes?: boolean +} + +export interface CopyFileOpts { + /** Preserve mode, owner, and timestamps where possible (cp -p). */ + preserveMetadata?: boolean +} + +// realpath's readlink walk is bounded so a symlink cycle the server didn't +// already reject with ELOOP can't spin forever on the client. +const REALPATH_MAX_HOPS = 40 + +/** + * A per-sandbox file system. Methods mirror `node:fs/promises`. Get an instance + * via `sandbox.fs()`. + */ +export class FileSystem { + /** The sandbox this file system is bound to. */ + readonly sandboxId: string + private readonly client: SandboxClient + + /** @internal Constructed by `Sandbox.fs`. */ + constructor(opts: {client: SandboxClient; sandboxId: string}) { + this.client = opts.client + this.sandboxId = opts.sandboxId + } + + private ref() { + return {sandbox: {selector: {case: 'id' as const, value: this.sandboxId}}} + } + + // ─── File I/O ───────────────────────────────────────────────────────── + + /** + * Read an entire file. Returns a Buffer, or a string when `opts.encoding` is + * set. Backed by the streaming ReadFile method; the chunks are concatenated + * here. For very large files, prefer streaming the chunks yourself. + */ + async readFile(path: string, opts?: ReadFileOpts): Promise { + const chunks: Uint8Array[] = [] + try { + for await (const chunk of this.client.rpc.readFile({...this.ref(), path})) { + if (chunk.data.length > 0) chunks.push(chunk.data) + } + } catch (err) { + throw toFileSystemError(err, 'open', path) + } + const buf = Buffer.concat(chunks) + return opts?.encoding ? buf.toString(opts.encoding) : buf + } + + /** + * Write `data` to `path`, replacing any existing contents. With + * `opts.recursive`, missing parent directories are created first. + */ + async writeFile(path: string, data: Buffer | Uint8Array | string, opts?: WriteFileOpts): Promise { + await this.writeStream(path, data, {mode: opts?.mode, recursive: opts?.recursive, append: false}, 'write') + } + + /** + * Append `data` to `path`, creating it if needed. Composed from WriteFile + * with append semantics; there is no dedicated append method. + */ + async appendFile(path: string, data: Buffer | Uint8Array | string, opts?: WriteFileOpts): Promise { + await this.writeStream(path, data, {mode: opts?.mode, recursive: opts?.recursive, append: true}, 'open') + } + + private async writeStream( + path: string, + data: Buffer | Uint8Array | string, + init: {mode?: number; recursive?: boolean; append: boolean}, + syscall: string, + ): Promise { + if (init.mode !== undefined) assertValidMode(init.mode, syscall, path) + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data) + const ref = this.ref() + async function* requestStream() { + yield create(WriteFileRequestSchema, { + input: { + case: 'init', + value: { + sandbox: ref.sandbox, + path, + mode: init.mode, + append: init.append, + createDirectories: init.recursive, + }, + }, + }) + // Split the payload into frames that stay under the gRPC message ceiling. + // subarray shares the backing buffer, so a small write still allocates + // nothing extra; an empty write sends no data frame at all (tee creates + // the empty file from the init message alone). + for (let offset = 0; offset < bytes.byteLength; offset += WRITE_CHUNK_BYTES) { + yield create(WriteFileRequestSchema, { + input: {case: 'data', value: bytes.subarray(offset, offset + WRITE_CHUNK_BYTES)}, + }) + } + } + try { + await this.client.rpc.writeFile(requestStream()) + } catch (err) { + throw toFileSystemError(err, syscall, path) + } + } + + // ─── Directory ops ────────────────────────────────────────────────────── + + async mkdir(path: string, opts?: MkdirOpts): Promise { + if (opts?.mode !== undefined) assertValidMode(opts.mode, 'mkdir', path) + try { + await this.client.rpc.mkdir({...this.ref(), path, recursive: opts?.recursive, mode: opts?.mode}) + } catch (err) { + throw toFileSystemError(err, 'mkdir', path) + } + } + + /** + * List directory entries. Returns `string[]` by default, or `DirEntry[]` when + * `opts.withFileTypes` is set. + */ + async readdir(path: string, opts?: ReaddirOpts): Promise { + let response + try { + response = await this.client.rpc.readDir({...this.ref(), path, withFileTypes: opts?.withFileTypes}) + } catch (err) { + throw toFileSystemError(err, 'scandir', path) + } + if (opts?.withFileTypes) { + return response.entries.map((e) => new DirEntry({name: e.name, type: fileTypeName(e.type)})) + } + return response.entries.map((e) => e.name) + } + + /** + * Create a uniquely named temporary directory. `prefix` is suffixed with a + * random string. Composed from mkdir with a retry on a name collision; there + * is no dedicated method. + */ + async mkdtemp(prefix: string): Promise { + for (let attempt = 0; attempt < 8; attempt++) { + const suffix = Math.random().toString(36).slice(2, 8) + const path = `${prefix}${suffix}` + try { + await this.client.rpc.mkdir({...this.ref(), path}) + return path + } catch (err) { + const fsErr = toFileSystemError(err, 'mkdir', path) + // Retry only on a name collision; anything else is a real failure. + if (fsErr.code !== 'EEXIST') throw fsErr + } + } + throw toFileSystemError(new Error('mkdtemp exhausted its retries'), 'mkdir', prefix) + } + + // ─── Stat family ────────────────────────────────────────────────────── + + /** Stat, following symlinks. */ + stat(path: string): Promise { + return this.statImpl(path, true) + } + + /** Lstat β€” stat the link itself rather than its target. */ + lstat(path: string): Promise { + return this.statImpl(path, false) + } + + private async statImpl(path: string, followSymlinks: boolean): Promise { + let response + try { + response = await this.client.rpc.stat({...this.ref(), path, followSymlinks}) + } catch (err) { + throw toFileSystemError(err, followSymlinks ? 'stat' : 'lstat', path) + } + return new StatResult({ + path: response.path, + size: Number(response.size), + mode: response.mode, + uname: response.uname, + gname: response.gname, + mtime: new Date(Number(response.mtimeUnixSeconds) * 1000), + type: fileTypeName(response.type), + }) + } + + /** + * Resolve `path` to its canonical form by walking symlinks. Composed from + * lstat + readlink with a hop limit, since the server exposes no one-shot + * realpath; the native backing can optimize this behind the unchanged + * surface later. Pathological per-component resolution (a symlink in the + * middle of a path) is not handled here yet β€” see the SDK reference. + */ + async realpath(path: string): Promise { + let current = path + for (let hop = 0; hop < REALPATH_MAX_HOPS; hop++) { + let info: StatResult + try { + info = await this.lstat(current) + } catch (err) { + // A missing or unreadable component surfaces as the underlying lstat + // error. Pass an already-classified FileSystemError through unchanged: + // Node's realpath likewise reports the underlying lstat syscall (an + // ENOENT here carries syscall 'lstat'), so preserving it keeps parity. + // Only an unclassified error gets wrapped under the realpath syscall. + if (err instanceof Error && 'code' in err) throw err + throw toFileSystemError(err, 'realpath', path) + } + if (info.type !== 'symlink') return current + const target = await this.readlink(current) + // An absolute target replaces the path; a relative one resolves against + // the link's directory. + current = target.startsWith('/') ? target : joinPath(dirname(current), target) + } + // Hit the hop limit: the chain is circular or pathologically deep. Node + // reports this as ELOOP, so build the code directly rather than routing a + // plain Error through toFileSystemError (which would classify as OTHER). + throw new FileSystemError({ + code: 'ELOOP', + syscall: 'realpath', + path, + message: `ELOOP: too many symbolic links, realpath '${path}'`, + }) + } + + // ─── Mutation ─────────────────────────────────────────────────────────── + + /** Remove a file or symlink. */ + unlink(path: string): Promise { + return this.removeImpl(path, {recursive: false, force: false}, 'unlink') + } + + /** Remove a path, optionally recursively and/or ignoring a missing target. */ + rm(path: string, opts?: RmOpts): Promise { + return this.removeImpl(path, {recursive: opts?.recursive ?? false, force: opts?.force ?? false}, 'unlink') + } + + /** Remove an empty directory. Fails with ENOTEMPTY if it isn't empty. */ + rmdir(path: string): Promise { + return this.removeImpl(path, {recursive: false, force: false}, 'rmdir') + } + + private async removeImpl(path: string, opts: {recursive: boolean; force: boolean}, syscall: string): Promise { + try { + await this.client.rpc.remove({...this.ref(), path, recursive: opts.recursive, ignoreMissing: opts.force}) + } catch (err) { + throw toFileSystemError(err, syscall, path) + } + } + + async rename(from: string, to: string): Promise { + try { + await this.client.rpc.rename({...this.ref(), fromPath: from, toPath: to}) + } catch (err) { + throw toFileSystemError(err, 'rename', from) + } + } + + async copyFile(from: string, to: string, opts?: CopyFileOpts): Promise { + try { + await this.client.rpc.copyFile({ + ...this.ref(), + fromPath: from, + toPath: to, + preserveMetadata: opts?.preserveMetadata, + }) + } catch (err) { + throw toFileSystemError(err, 'copyfile', from) + } + } + + async truncate(path: string, size = 0): Promise { + if (!Number.isInteger(size) || size < 0) { + throw new FileSystemError({ + code: 'EINVAL', + syscall: 'ftruncate', + path, + message: `ftruncate '${path}': size must be a non-negative integer`, + }) + } + try { + await this.client.rpc.truncate({...this.ref(), path, size: BigInt(size)}) + } catch (err) { + throw toFileSystemError(err, 'ftruncate', path) + } + } + + /** chmod. `mode` accepts an octal integer (`0o755`) or an octal string (`"755"`). */ + async chmod(path: string, mode: number | string): Promise { + const numeric = typeof mode === 'string' ? parseInt(mode, 8) : mode + assertValidMode(numeric, 'chmod', path) + try { + await this.client.rpc.chmod({...this.ref(), path, mode: numeric}) + } catch (err) { + throw toFileSystemError(err, 'chmod', path) + } + } + + async chown(path: string, uid: number, gid: number): Promise { + try { + await this.client.rpc.chown({...this.ref(), path, uid, gid}) + } catch (err) { + throw toFileSystemError(err, 'chown', path) + } + } + + // ─── Symlinks ───────────────────────────────────────────────────────── + + async symlink(target: string, linkPath: string): Promise { + try { + await this.client.rpc.symlink({...this.ref(), target, linkPath}) + } catch (err) { + throw toFileSystemError(err, 'symlink', linkPath) + } + } + + async readlink(path: string): Promise { + try { + const response = await this.client.rpc.readlink({...this.ref(), path}) + return response.target + } catch (err) { + throw toFileSystemError(err, 'readlink', path) + } + } + + // ─── Access / probe ───────────────────────────────────────────────────── + + /** Check access to `path`. Resolves on success, rejects with a FileSystemError otherwise. */ + async access(path: string, mode?: number): Promise { + try { + await this.client.rpc.access({...this.ref(), path, mode}) + } catch (err) { + throw toFileSystemError(err, 'access', path) + } + } + + /** + * Whether `path` exists. Composed from stat + catching ENOENT. Not part of + * `node:fs/promises`, but an SDK convenience callers ask for. Errors other + * than ENOENT (for example EACCES on an unreadable parent) propagate. + */ + async exists(path: string): Promise { + try { + await this.stat(path) + return true + } catch (err) { + if (err instanceof Error && 'code' in err && (err as {code: unknown}).code === 'ENOENT') return false + throw err + } + } +} + +// ─── Path helpers ───────────────────────────────────────────────────────── +// Minimal POSIX path helpers for the realpath walk; the SDK doesn't pull in +// node:path so it stays usable in non-Node runtimes. + +function dirname(path: string): string { + const idx = path.lastIndexOf('/') + if (idx < 0) return '.' + if (idx === 0) return '/' + return path.slice(0, idx) +} + +function joinPath(dir: string, name: string): string { + if (dir === '/') return `/${name}` + return `${dir}/${name}` +} diff --git a/packages/sandbox/src/gen/depot/sandbox/v1/command_pb.ts b/packages/sandbox/src/gen/depot/sandbox/v1/command_pb.ts new file mode 100644 index 0000000..0c99c8c --- /dev/null +++ b/packages/sandbox/src/gen/depot/sandbox/v1/command_pb.ts @@ -0,0 +1,523 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=js" +// @generated from file depot/sandbox/v1/command.proto (package depot.sandbox.v1, syntax proto3) +/* eslint-disable */ + +import type {Message} from '@bufbuild/protobuf' +import type {GenEnum, GenFile, GenMessage} from '@bufbuild/protobuf/codegenv2' +import {enumDesc, fileDesc, messageDesc} from '@bufbuild/protobuf/codegenv2' +import type {Timestamp} from '@bufbuild/protobuf/wkt' +import {file_google_protobuf_timestamp} from '@bufbuild/protobuf/wkt' +import type {SandboxRef} from './refs_pb.js' +import {file_depot_sandbox_v1_refs} from './refs_pb.js' + +/** + * Describes the file depot/sandbox/v1/command.proto. + */ +export const file_depot_sandbox_v1_command: GenFile = /*@__PURE__*/ fileDesc( + 'Ch5kZXBvdC9zYW5kYm94L3YxL2NvbW1hbmQucHJvdG8SEGRlcG90LnNhbmRib3gudjEimAQKF1NhbmRib3hDb21tYW5kRXhlY3V0aW9uEg4KBmNtZF9pZBgBIAEoCRISCgpzYW5kYm94X2lkGAIgASgJEgsKA2NtZBgDIAEoCRIMCgRhcmdzGAQgAygJEhAKA2N3ZBgFIAEoCUgAiAEBEj8KA2VudhgGIAMoCzIyLmRlcG90LnNhbmRib3gudjEuU2FuZGJveENvbW1hbmRFeGVjdXRpb24uRW52RW50cnkSDAoEc3VkbxgHIAEoCBIQCghkZXRhY2hlZBgIIAEoCBI/CgZzdGF0dXMYCSABKA4yLy5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hDb21tYW5kRXhlY3V0aW9uU3RhdHVzEi4KCnN0YXJ0ZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjQKC2ZpbmlzaGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgBiAEBEhYKCWV4aXRfY29kZRgMIAEoBUgCiAEBEhwKFHN0ZG91dF9ieXRlc19lbWl0dGVkGA0gASgDEhwKFHN0ZGVycl9ieXRlc19lbWl0dGVkGA4gASgDGioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAFCBgoEX2N3ZEIOCgxfZmluaXNoZWRfYXRCDAoKX2V4aXRfY29kZSK8BwocU2FuZGJveENvbW1hbmRFeGVjdXRpb25FdmVudBJJCgdzdGFydGVkGAEgASgLMjYuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94Q29tbWFuZEV4ZWN1dGlvbkV2ZW50LlN0YXJ0ZWRIABJMCgZzdGRvdXQYAiABKAsyOi5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hDb21tYW5kRXhlY3V0aW9uRXZlbnQuU3Rkb3V0Qnl0ZXNIABJMCgZzdGRlcnIYAyABKAsyOi5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hDb21tYW5kRXhlY3V0aW9uRXZlbnQuU3RkZXJyQnl0ZXNIABJLCghmaW5pc2hlZBgEIAEoCzI3LmRlcG90LnNhbmRib3gudjEuU2FuZGJveENvbW1hbmRFeGVjdXRpb25FdmVudC5GaW5pc2hlZEgAEkUKBWVycm9yGAUgASgLMjQuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94Q29tbWFuZEV4ZWN1dGlvbkV2ZW50LkVycm9ySAASUgoHZXZpY3RlZBgGIAEoCzI/LmRlcG90LnNhbmRib3gudjEuU2FuZGJveENvbW1hbmRFeGVjdXRpb25FdmVudC5FdmljdGVkRWFybHlEYXRhSAAaSQoHU3RhcnRlZBIOCgZjbWRfaWQYASABKAkSLgoKc3RhcnRlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaXwoLU3Rkb3V0Qnl0ZXMSDAoEZGF0YRgBIAEoDBITCgtieXRlX29mZnNldBgCIAEoAxItCgl0aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGl8KC1N0ZGVyckJ5dGVzEgwKBGRhdGEYASABKAwSEwoLYnl0ZV9vZmZzZXQYAiABKAMSLQoJdGltZXN0YW1wGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBpOCghGaW5pc2hlZBIRCglleGl0X2NvZGUYASABKAUSLwoLZmluaXNoZWRfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGhcKBUVycm9yEg4KBnJlYXNvbhgBIAEoCRpOChBFdmljdGVkRWFybHlEYXRhEhwKFGRyb3BwZWRfYnl0ZXNfc3Rkb3V0GAEgASgDEhwKFGRyb3BwZWRfYnl0ZXNfc3RkZXJyGAIgASgDQgcKBWV2ZW50Ip4CChFSdW5Db21tYW5kUmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEgsKA2NtZBgCIAEoCRIMCgRhcmdzGAMgAygJEhAKA2N3ZBgEIAEoCUgAiAEBEjkKA2VudhgFIAMoCzIsLmRlcG90LnNhbmRib3gudjEuUnVuQ29tbWFuZFJlcXVlc3QuRW52RW50cnkSEQoEc3VkbxgGIAEoCEgBiAEBEhUKCGRldGFjaGVkGAcgASgISAKIAQEaKgoIRW52RW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIGCgRfY3dkQgcKBV9zdWRvQgsKCV9kZXRhY2hlZCq2AgodU2FuZGJveENvbW1hbmRFeGVjdXRpb25TdGF0dXMSMAosU0FOREJPWF9DT01NQU5EX0VYRUNVVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABIsCihTQU5EQk9YX0NPTU1BTkRfRVhFQ1VUSU9OX1NUQVRVU19QRU5ESU5HEAESLAooU0FOREJPWF9DT01NQU5EX0VYRUNVVElPTl9TVEFUVVNfUlVOTklORxACEi0KKVNBTkRCT1hfQ09NTUFORF9FWEVDVVRJT05fU1RBVFVTX0ZJTklTSEVEEAMSKwonU0FOREJPWF9DT01NQU5EX0VYRUNVVElPTl9TVEFUVVNfRkFJTEVEEAQSKwonU0FOREJPWF9DT01NQU5EX0VYRUNVVElPTl9TVEFUVVNfS0lMTEVEEAViBnByb3RvMw', + [file_depot_sandbox_v1_refs, file_google_protobuf_timestamp], +) + +/** + * A record of a single command run inside a sandbox, including how it was + * invoked and its current state. Returned by the command lookup methods. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecution + */ +export type SandboxCommandExecution = Message<'depot.sandbox.v1.SandboxCommandExecution'> & { + /** + * Unique identifier for this command execution, assigned by the server. + * + * @generated from field: string cmd_id = 1; + */ + cmdId: string + + /** + * Unique identifier for the sandbox this command ran in. + * + * @generated from field: string sandbox_id = 2; + */ + sandboxId: string + + /** + * The executable that was run. + * + * @generated from field: string cmd = 3; + */ + cmd: string + + /** + * Positional arguments passed to the command. Empty when none were supplied. + * + * @generated from field: repeated string args = 4; + */ + args: string[] + + /** + * Working directory the command ran in. Unset when the caller did not request one. + * + * @generated from field: optional string cwd = 5; + */ + cwd?: string + + /** + * Environment the command ran with. This is the merge of the sandbox-level + * environment set when the sandbox was created and the environment supplied + * with the command, with the per-command values taking precedence on any + * key that appears in both. + * + * @generated from field: map env = 6; + */ + env: {[key: string]: string} + + /** + * Whether the command was run through `sudo -E`. + * + * @generated from field: bool sudo = 7; + */ + sudo: boolean + + /** + * Whether the command was run detached. Detached mode is in beta: the command + * runs fire-and-forget and its output is not retained, so it can't be + * retrieved through this API yet. + * + * @generated from field: bool detached = 8; + */ + detached: boolean + + /** + * Current state of the command. This is independent of the exit code: a + * command that exits with a nonzero code still reports a finished status, + * which is the normal outcome for a failed program. + * + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionStatus status = 9; + */ + status: SandboxCommandExecutionStatus + + /** + * Time the command started, in RFC 3339 format. + * + * @generated from field: google.protobuf.Timestamp started_at = 10; + */ + startedAt?: Timestamp + + /** + * Time the command reached a terminal state, in RFC 3339 format. Unset until + * the command has finished, failed, or been killed. + * + * @generated from field: optional google.protobuf.Timestamp finished_at = 11; + */ + finishedAt?: Timestamp + + /** + * Exit code the process returned. Set when the process exited on its own; + * unset when the command failed to start or was killed before producing one. + * + * @generated from field: optional int32 exit_code = 12; + */ + exitCode?: number + + /** + * Total number of stdout bytes emitted so far. Tracked separately from + * stderr so callers can follow each stream's byte offset independently. + * + * @generated from field: int64 stdout_bytes_emitted = 13; + */ + stdoutBytesEmitted: bigint + + /** + * Total number of stderr bytes emitted so far. Tracked separately from + * stdout so callers can follow each stream's byte offset independently. + * + * @generated from field: int64 stderr_bytes_emitted = 14; + */ + stderrBytesEmitted: bigint +} + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecution. + * Use `create(SandboxCommandExecutionSchema)` to create a new message. + */ +export const SandboxCommandExecutionSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_command, + 0, +) + +/** + * A single event in the stream a command produces while it runs. Each event is + * one of: the command starting, a chunk of stdout or stderr, the command + * finishing, a recoverable error, or a notice that earlier output was dropped. + * Output chunks are raw bytes; callers decode to text themselves. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent + */ +export type SandboxCommandExecutionEvent = Message<'depot.sandbox.v1.SandboxCommandExecutionEvent'> & { + /** + * @generated from oneof depot.sandbox.v1.SandboxCommandExecutionEvent.event + */ + event: + | { + /** + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionEvent.Started started = 1; + */ + value: SandboxCommandExecutionEvent_Started + case: 'started' + } + | { + /** + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionEvent.StdoutBytes stdout = 2; + */ + value: SandboxCommandExecutionEvent_StdoutBytes + case: 'stdout' + } + | { + /** + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionEvent.StderrBytes stderr = 3; + */ + value: SandboxCommandExecutionEvent_StderrBytes + case: 'stderr' + } + | { + /** + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionEvent.Finished finished = 4; + */ + value: SandboxCommandExecutionEvent_Finished + case: 'finished' + } + | { + /** + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionEvent.Error error = 5; + */ + value: SandboxCommandExecutionEvent_Error + case: 'error' + } + | { + /** + * @generated from field: depot.sandbox.v1.SandboxCommandExecutionEvent.EvictedEarlyData evicted = 6; + */ + value: SandboxCommandExecutionEvent_EvictedEarlyData + case: 'evicted' + } + | {case: undefined; value?: undefined} +} + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent. + * Use `create(SandboxCommandExecutionEventSchema)` to create a new message. + */ +export const SandboxCommandExecutionEventSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_command, + 1, +) + +/** + * The first event on every stream, signaling that the command has started. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent.Started + */ +export type SandboxCommandExecutionEvent_Started = Message<'depot.sandbox.v1.SandboxCommandExecutionEvent.Started'> & { + /** + * Unique identifier for this command execution, assigned by the server and + * echoed here so the caller can correlate the stream with the command. + * + * @generated from field: string cmd_id = 1; + */ + cmdId: string + + /** + * Time the command started, in RFC 3339 format. + * + * @generated from field: google.protobuf.Timestamp started_at = 2; + */ + startedAt?: Timestamp +} + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent.Started. + * Use `create(SandboxCommandExecutionEvent_StartedSchema)` to create a new message. + */ +export const SandboxCommandExecutionEvent_StartedSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_command, 1, 0) + +/** + * A chunk of standard output produced by the command. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent.StdoutBytes + */ +export type SandboxCommandExecutionEvent_StdoutBytes = + Message<'depot.sandbox.v1.SandboxCommandExecutionEvent.StdoutBytes'> & { + /** + * Raw stdout bytes. The contents are not guaranteed to be valid UTF-8. + * + * @generated from field: bytes data = 1; + */ + data: Uint8Array + + /** + * Total number of stdout bytes emitted through the end of this chunk. + * + * @generated from field: int64 byte_offset = 2; + */ + byteOffset: bigint + + /** + * Time this chunk was emitted, in RFC 3339 format. + * + * @generated from field: google.protobuf.Timestamp timestamp = 3; + */ + timestamp?: Timestamp + } + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent.StdoutBytes. + * Use `create(SandboxCommandExecutionEvent_StdoutBytesSchema)` to create a new message. + */ +export const SandboxCommandExecutionEvent_StdoutBytesSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_command, 1, 1) + +/** + * A chunk of standard error produced by the command. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent.StderrBytes + */ +export type SandboxCommandExecutionEvent_StderrBytes = + Message<'depot.sandbox.v1.SandboxCommandExecutionEvent.StderrBytes'> & { + /** + * Raw stderr bytes. The contents are not guaranteed to be valid UTF-8. + * + * @generated from field: bytes data = 1; + */ + data: Uint8Array + + /** + * Total number of stderr bytes emitted through the end of this chunk. + * + * @generated from field: int64 byte_offset = 2; + */ + byteOffset: bigint + + /** + * Time this chunk was emitted, in RFC 3339 format. + * + * @generated from field: google.protobuf.Timestamp timestamp = 3; + */ + timestamp?: Timestamp + } + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent.StderrBytes. + * Use `create(SandboxCommandExecutionEvent_StderrBytesSchema)` to create a new message. + */ +export const SandboxCommandExecutionEvent_StderrBytesSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_command, 1, 2) + +/** + * The terminal event when a command runs to completion, signaling that the + * process exited. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent.Finished + */ +export type SandboxCommandExecutionEvent_Finished = + Message<'depot.sandbox.v1.SandboxCommandExecutionEvent.Finished'> & { + /** + * Exit code the process returned. On POSIX systems this is 0 through 255, + * where 0 indicates success. + * + * @generated from field: int32 exit_code = 1; + */ + exitCode: number + + /** + * Time the process exited, in RFC 3339 format. + * + * @generated from field: google.protobuf.Timestamp finished_at = 2; + */ + finishedAt?: Timestamp + } + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent.Finished. + * Use `create(SandboxCommandExecutionEvent_FinishedSchema)` to create a new message. + */ +export const SandboxCommandExecutionEvent_FinishedSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_command, 1, 3) + +/** + * A recoverable failure that does not end the stream, for example a single + * output chunk being lost while more output is still to come. Fatal errors + * are reported through the usual error channel rather than this event. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent.Error + */ +export type SandboxCommandExecutionEvent_Error = Message<'depot.sandbox.v1.SandboxCommandExecutionEvent.Error'> & { + /** + * Human-readable description of what went wrong. + * + * @generated from field: string reason = 1; + */ + reason: string +} + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent.Error. + * Use `create(SandboxCommandExecutionEvent_ErrorSchema)` to create a new message. + */ +export const SandboxCommandExecutionEvent_ErrorSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_command, 1, 4) + +/** + * A notice that some output was dropped before this consumer began reading, + * which can happen when reattaching to a command that is already running. + * The byte counts let the consumer account for the gap. + * + * @generated from message depot.sandbox.v1.SandboxCommandExecutionEvent.EvictedEarlyData + */ +export type SandboxCommandExecutionEvent_EvictedEarlyData = + Message<'depot.sandbox.v1.SandboxCommandExecutionEvent.EvictedEarlyData'> & { + /** + * Number of stdout bytes dropped before this consumer began reading. + * + * @generated from field: int64 dropped_bytes_stdout = 1; + */ + droppedBytesStdout: bigint + + /** + * Number of stderr bytes dropped before this consumer began reading. + * + * @generated from field: int64 dropped_bytes_stderr = 2; + */ + droppedBytesStderr: bigint + } + +/** + * Describes the message depot.sandbox.v1.SandboxCommandExecutionEvent.EvictedEarlyData. + * Use `create(SandboxCommandExecutionEvent_EvictedEarlyDataSchema)` to create a new message. + */ +export const SandboxCommandExecutionEvent_EvictedEarlyDataSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_command, 1, 5) + +/** + * Request to run a command in a sandbox. The `RunCommand` method streams back a + * sequence of events that begins with a started event and ends with a finished + * event, or terminates with an error. + * + * @generated from message depot.sandbox.v1.RunCommandRequest + */ +export type RunCommandRequest = Message<'depot.sandbox.v1.RunCommandRequest'> & { + /** + * The sandbox to run the command in. + * + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * The executable to run. Looked up on the sandbox's PATH unless given as an + * absolute path. + * + * @generated from field: string cmd = 2; + */ + cmd: string + + /** + * Positional arguments to pass to the command. Empty by default. + * + * @generated from field: repeated string args = 3; + */ + args: string[] + + /** + * Working directory to run the command in. Default: the sandbox's default + * working directory. + * + * @generated from field: optional string cwd = 4; + */ + cwd?: string + + /** + * Environment variables for this command. These are merged with the + * sandbox-level environment set when the sandbox was created, and take + * precedence on any key that appears in both. Keys must be valid POSIX + * environment variable names, matching [A-Za-z_][A-Za-z0-9_]*. + * + * @generated from field: map env = 5; + */ + env: {[key: string]: string} + + /** + * Run the command through `sudo -E`, preserving the merged environment. + * Default: false. + * + * @generated from field: optional bool sudo = 6; + */ + sudo?: boolean + + /** + * Run the command detached (beta). The server returns once the command has + * started and the command keeps running in the sandbox, but its output is not + * retained and can't be retrieved yet, so the response stream ends right after + * the Started event. Reattaching to a detached command's output is a future + * API. Default: false. + * + * @generated from field: optional bool detached = 7; + */ + detached?: boolean +} + +/** + * Describes the message depot.sandbox.v1.RunCommandRequest. + * Use `create(RunCommandRequestSchema)` to create a new message. + */ +export const RunCommandRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_command, + 2, +) + +/** + * The lifecycle stage a command has reached. A command starts pending, begins + * running once the process is launched, and ends in one of the terminal states: + * finished if the process exited, failed if it could not start or the guest + * reported an error, or killed if it was signaled while running. + * + * @generated from enum depot.sandbox.v1.SandboxCommandExecutionStatus + */ +export enum SandboxCommandExecutionStatus { + /** + * @generated from enum value: SANDBOX_COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SANDBOX_COMMAND_EXECUTION_STATUS_PENDING = 1; + */ + PENDING = 1, + + /** + * @generated from enum value: SANDBOX_COMMAND_EXECUTION_STATUS_RUNNING = 2; + */ + RUNNING = 2, + + /** + * @generated from enum value: SANDBOX_COMMAND_EXECUTION_STATUS_FINISHED = 3; + */ + FINISHED = 3, + + /** + * @generated from enum value: SANDBOX_COMMAND_EXECUTION_STATUS_FAILED = 4; + */ + FAILED = 4, + + /** + * @generated from enum value: SANDBOX_COMMAND_EXECUTION_STATUS_KILLED = 5; + */ + KILLED = 5, +} + +/** + * Describes the enum depot.sandbox.v1.SandboxCommandExecutionStatus. + */ +export const SandboxCommandExecutionStatusSchema: GenEnum = /*@__PURE__*/ enumDesc( + file_depot_sandbox_v1_command, + 0, +) diff --git a/packages/sandbox/src/gen/depot/sandbox/v1/filesystem_pb.ts b/packages/sandbox/src/gen/depot/sandbox/v1/filesystem_pb.ts new file mode 100644 index 0000000..748b6df --- /dev/null +++ b/packages/sandbox/src/gen/depot/sandbox/v1/filesystem_pb.ts @@ -0,0 +1,1059 @@ +// File system operations against a sandbox. +// +// The server may back these operations with different implementations, but the +// message surface is the same regardless of which one is active. +// +// Errors are returned as Connect errors with a FileSystemErrorDetail attached. +// The SDK converts that detail into a Node-style Error carrying code, syscall, +// path, and errno, so callers can keep writing code against the shape of +// node:fs/promises. +// +// The methods themselves are declared on the sandbox service; this file only +// defines the messages and enums they use. + +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=js" +// @generated from file depot/sandbox/v1/filesystem.proto (package depot.sandbox.v1, syntax proto3) +/* eslint-disable */ + +import type {Message} from '@bufbuild/protobuf' +import type {GenEnum, GenFile, GenMessage} from '@bufbuild/protobuf/codegenv2' +import {enumDesc, fileDesc, messageDesc} from '@bufbuild/protobuf/codegenv2' +import type {SandboxRef} from './refs_pb.js' +import {file_depot_sandbox_v1_refs} from './refs_pb.js' + +/** + * Describes the file depot/sandbox/v1/filesystem.proto. + */ +export const file_depot_sandbox_v1_filesystem: GenFile = /*@__PURE__*/ fileDesc( + 'CiFkZXBvdC9zYW5kYm94L3YxL2ZpbGVzeXN0ZW0ucHJvdG8SEGRlcG90LnNhbmRib3gudjEijQEKFUZpbGVTeXN0ZW1FcnJvckRldGFpbBIzCgRjb2RlGAEgASgOMiUuZGVwb3Quc2FuZGJveC52MS5GaWxlU3lzdGVtRXJyb3JDb2RlEg8KB3N5c2NhbGwYAiABKAkSDAoEcGF0aBgDIAEoCRIUCgdtZXNzYWdlGAQgASgJSACIAQFCCgoIX21lc3NhZ2UijQEKDE1rZGlyUmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEgwKBHBhdGgYAiABKAkSFgoJcmVjdXJzaXZlGAMgASgISACIAQESEQoEbW9kZRgEIAEoDUgBiAEBQgwKCl9yZWN1cnNpdmVCBwoFX21vZGUiDwoNTWtkaXJSZXNwb25zZSJ8CgtTdGF0UmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEgwKBHBhdGgYAiABKAkSHAoPZm9sbG93X3N5bWxpbmtzGAMgASgISACIAQFCEgoQX2ZvbGxvd19zeW1saW5rcyKcAQoMU3RhdFJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEc2l6ZRgCIAEoAxIMCgRtb2RlGAMgASgNEigKBHR5cGUYBCABKA4yGi5kZXBvdC5zYW5kYm94LnYxLkZpbGVUeXBlEg0KBXVuYW1lGAUgASgJEg0KBWduYW1lGAYgASgJEhoKEm10aW1lX3VuaXhfc2Vjb25kcxgHIAEoAyJ/Cg5SZWFkRGlyUmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEgwKBHBhdGgYAiABKAkSHAoPd2l0aF9maWxlX3R5cGVzGAMgASgISACIAQFCEgoQX3dpdGhfZmlsZV90eXBlcyI+Cg9SZWFkRGlyUmVzcG9uc2USKwoHZW50cmllcxgBIAMoCzIaLmRlcG90LnNhbmRib3gudjEuRGlyRW50cnkiQgoIRGlyRW50cnkSDAoEbmFtZRgBIAEoCRIoCgR0eXBlGAIgASgOMhouZGVwb3Quc2FuZGJveC52MS5GaWxlVHlwZSKiAQoNUmVtb3ZlUmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEgwKBHBhdGgYAiABKAkSFgoJcmVjdXJzaXZlGAMgASgISACIAQESGwoOaWdub3JlX21pc3NpbmcYBCABKAhIAYgBAUIMCgpfcmVjdXJzaXZlQhEKD19pZ25vcmVfbWlzc2luZyIQCg5SZW1vdmVSZXNwb25zZSJiCg1SZW5hbWVSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSEQoJZnJvbV9wYXRoGAIgASgJEg8KB3RvX3BhdGgYAyABKAkiEAoOUmVuYW1lUmVzcG9uc2UimgEKD0NvcHlGaWxlUmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEhEKCWZyb21fcGF0aBgCIAEoCRIPCgd0b19wYXRoGAMgASgJEh4KEXByZXNlcnZlX21ldGFkYXRhGAQgASgISACIAQFCFAoSX3ByZXNlcnZlX21ldGFkYXRhIhIKEENvcHlGaWxlUmVzcG9uc2UiXAoPVHJ1bmNhdGVSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSDAoEcGF0aBgCIAEoCRIMCgRzaXplGAMgASgDIhIKEFRydW5jYXRlUmVzcG9uc2UiZwoMQ2htb2RSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSDAoEcGF0aBgCIAEoCRIRCgRtb2RlGAMgASgNSACIAQFCBwoFX21vZGUiDwoNQ2htb2RSZXNwb25zZSJ/CgxDaG93blJlcXVlc3QSLQoHc2FuZGJveBgBIAEoCzIcLmRlcG90LnNhbmRib3gudjEuU2FuZGJveFJlZhIMCgRwYXRoGAIgASgJEhAKA3VpZBgDIAEoDUgAiAEBEhAKA2dpZBgEIAEoDUgBiAEBQgYKBF91aWRCBgoEX2dpZCIPCg1DaG93blJlc3BvbnNlImIKDlN5bWxpbmtSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSDgoGdGFyZ2V0GAIgASgJEhEKCWxpbmtfcGF0aBgDIAEoCSIRCg9TeW1saW5rUmVzcG9uc2UiTgoPUmVhZGxpbmtSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSDAoEcGF0aBgCIAEoCSIiChBSZWFkbGlua1Jlc3BvbnNlEg4KBnRhcmdldBgBIAEoCSJoCg1BY2Nlc3NSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSDAoEcGF0aBgCIAEoCRIRCgRtb2RlGAMgASgNSACIAQFCBwoFX21vZGUiEAoOQWNjZXNzUmVzcG9uc2UiTgoPUmVhZEZpbGVSZXF1ZXN0Ei0KB3NhbmRib3gYASABKAsyHC5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3hSZWYSDAoEcGF0aBgCIAEoCSIzCglGaWxlQ2h1bmsSDAoEZGF0YRgBIAEoDBIQCgNlb2YYAiABKAhIAIgBAUIGCgRfZW9mIp4CChBXcml0ZUZpbGVSZXF1ZXN0EjcKBGluaXQYASABKAsyJy5kZXBvdC5zYW5kYm94LnYxLldyaXRlRmlsZVJlcXVlc3QuSW5pdEgAEg4KBGRhdGEYAiABKAxIABq3AQoESW5pdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEgwKBHBhdGgYAiABKAkSEQoEbW9kZRgDIAEoDUgAiAEBEhMKBmFwcGVuZBgEIAEoCEgBiAEBEh8KEmNyZWF0ZV9kaXJlY3RvcmllcxgFIAEoCEgCiAEBQgcKBV9tb2RlQgkKB19hcHBlbmRCFQoTX2NyZWF0ZV9kaXJlY3Rvcmllc0IHCgVpbnB1dCIqChFXcml0ZUZpbGVSZXNwb25zZRIVCg1ieXRlc193cml0dGVuGAEgASgDKrQHChNGaWxlU3lzdGVtRXJyb3JDb2RlEiUKIUZJTEVTWVNURU1fRVJST1JfQ09ERV9VTlNQRUNJRklFRBAAEh8KG0ZJTEVTWVNURU1fRVJST1JfQ09ERV9FUEVSTRABEiAKHEZJTEVTWVNURU1fRVJST1JfQ09ERV9FTk9FTlQQAhIfChtGSUxFU1lTVEVNX0VSUk9SX0NPREVfRUlOVFIQBBIdChlGSUxFU1lTVEVNX0VSUk9SX0NPREVfRUlPEAUSHwobRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VCQURGEAkSIAocRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VOT01FTRAMEiAKHEZJTEVTWVNURU1fRVJST1JfQ09ERV9FQUNDRVMQDRIfChtGSUxFU1lTVEVNX0VSUk9SX0NPREVfRUJVU1kQEBIgChxGSUxFU1lTVEVNX0VSUk9SX0NPREVfRUVYSVNUEBESHwobRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VYREVWEBISIQodRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VOT1RESVIQFBIgChxGSUxFU1lTVEVNX0VSUk9SX0NPREVfRUlTRElSEBUSIAocRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VJTlZBTBAWEiAKHEZJTEVTWVNURU1fRVJST1JfQ09ERV9FTkZJTEUQFxIgChxGSUxFU1lTVEVNX0VSUk9SX0NPREVfRU1GSUxFEBgSIQodRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VUWFRCU1kQGhIfChtGSUxFU1lTVEVNX0VSUk9SX0NPREVfRUZCSUcQGxIgChxGSUxFU1lTVEVNX0VSUk9SX0NPREVfRU5PU1BDEBwSHwobRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VST0ZTEB4SIAocRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VNTElOSxAfEiYKIkZJTEVTWVNURU1fRVJST1JfQ09ERV9FTkFNRVRPT0xPTkcQJBIjCh9GSUxFU1lTVEVNX0VSUk9SX0NPREVfRU5PVEVNUFRZECcSHwobRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VMT09QECgSJAogRklMRVNZU1RFTV9FUlJPUl9DT0RFX0VPUE5PVFNVUFAQXxIgChxGSUxFU1lTVEVNX0VSUk9SX0NPREVfRURRVU9UEHoSIAobRklMRVNZU1RFTV9FUlJPUl9DT0RFX09USEVSEIAgKs8BCghGaWxlVHlwZRIZChVGSUxFX1RZUEVfVU5TUEVDSUZJRUQQABISCg5GSUxFX1RZUEVfRklMRRABEhcKE0ZJTEVfVFlQRV9ESVJFQ1RPUlkQAhIVChFGSUxFX1RZUEVfU1lNTElOSxADEhoKFkZJTEVfVFlQRV9CTE9DS19ERVZJQ0UQBBIeChpGSUxFX1RZUEVfQ0hBUkFDVEVSX0RFVklDRRAFEhIKDkZJTEVfVFlQRV9GSUZPEAYSFAoQRklMRV9UWVBFX1NPQ0tFVBAHYgZwcm90bzM', + [file_depot_sandbox_v1_refs], +) + +/** + * The error detail the server attaches to a Connect error's details. The SDK + * reads it back and uses it to build a Node-style FileSystemError. + * + * @generated from message depot.sandbox.v1.FileSystemErrorDetail + */ +export type FileSystemErrorDetail = Message<'depot.sandbox.v1.FileSystemErrorDetail'> & { + /** + * @generated from field: depot.sandbox.v1.FileSystemErrorCode code = 1; + */ + code: FileSystemErrorCode + + /** + * @generated from field: string syscall = 2; + */ + syscall: string + + /** + * @generated from field: string path = 3; + */ + path: string + + /** + * @generated from field: optional string message = 4; + */ + message?: string +} + +/** + * Describes the message depot.sandbox.v1.FileSystemErrorDetail. + * Use `create(FileSystemErrorDetailSchema)` to create a new message. + */ +export const FileSystemErrorDetailSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 0, +) + +/** + * @generated from message depot.sandbox.v1.MkdirRequest + */ +export type MkdirRequest = Message<'depot.sandbox.v1.MkdirRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * When true, create parent directories as needed (mkdir -p). When false (the + * default), a missing parent directory fails with ENOENT. + * + * @generated from field: optional bool recursive = 3; + */ + recursive?: boolean + + /** + * Octal permission mode for the new directory. When unset, the server applies + * its umask. + * + * @generated from field: optional uint32 mode = 4; + */ + mode?: number +} + +/** + * Describes the message depot.sandbox.v1.MkdirRequest. + * Use `create(MkdirRequestSchema)` to create a new message. + */ +export const MkdirRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 1, +) + +/** + * @generated from message depot.sandbox.v1.MkdirResponse + */ +export type MkdirResponse = Message<'depot.sandbox.v1.MkdirResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.MkdirResponse. + * Use `create(MkdirResponseSchema)` to create a new message. + */ +export const MkdirResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 2, +) + +/** + * @generated from message depot.sandbox.v1.StatRequest + */ +export type StatRequest = Message<'depot.sandbox.v1.StatRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * When true (the default), follow symlinks like stat. When false, report on + * the link itself like lstat. + * + * @generated from field: optional bool follow_symlinks = 3; + */ + followSymlinks?: boolean +} + +/** + * Describes the message depot.sandbox.v1.StatRequest. + * Use `create(StatRequestSchema)` to create a new message. + */ +export const StatRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_filesystem, 3) + +/** + * @generated from message depot.sandbox.v1.StatResponse + */ +export type StatResponse = Message<'depot.sandbox.v1.StatResponse'> & { + /** + * @generated from field: string path = 1; + */ + path: string + + /** + * @generated from field: int64 size = 2; + */ + size: bigint + + /** + * @generated from field: uint32 mode = 3; + */ + mode: number + + /** + * @generated from field: depot.sandbox.v1.FileType type = 4; + */ + type: FileType + + /** + * @generated from field: string uname = 5; + */ + uname: string + + /** + * @generated from field: string gname = 6; + */ + gname: string + + /** + * @generated from field: int64 mtime_unix_seconds = 7; + */ + mtimeUnixSeconds: bigint +} + +/** + * Describes the message depot.sandbox.v1.StatResponse. + * Use `create(StatResponseSchema)` to create a new message. + */ +export const StatResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 4, +) + +/** + * @generated from message depot.sandbox.v1.ReadDirRequest + */ +export type ReadDirRequest = Message<'depot.sandbox.v1.ReadDirRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * Matches the withFileTypes option of node:fs/promises readdir. The response + * entries always carry name and type; when this is false the SDK exposes them + * as a plain array of names instead. + * + * @generated from field: optional bool with_file_types = 3; + */ + withFileTypes?: boolean +} + +/** + * Describes the message depot.sandbox.v1.ReadDirRequest. + * Use `create(ReadDirRequestSchema)` to create a new message. + */ +export const ReadDirRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 5, +) + +/** + * @generated from message depot.sandbox.v1.ReadDirResponse + */ +export type ReadDirResponse = Message<'depot.sandbox.v1.ReadDirResponse'> & { + /** + * @generated from field: repeated depot.sandbox.v1.DirEntry entries = 1; + */ + entries: DirEntry[] +} + +/** + * Describes the message depot.sandbox.v1.ReadDirResponse. + * Use `create(ReadDirResponseSchema)` to create a new message. + */ +export const ReadDirResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 6, +) + +/** + * @generated from message depot.sandbox.v1.DirEntry + */ +export type DirEntry = Message<'depot.sandbox.v1.DirEntry'> & { + /** + * @generated from field: string name = 1; + */ + name: string + + /** + * @generated from field: depot.sandbox.v1.FileType type = 2; + */ + type: FileType +} + +/** + * Describes the message depot.sandbox.v1.DirEntry. + * Use `create(DirEntrySchema)` to create a new message. + */ +export const DirEntrySchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_filesystem, 7) + +/** + * @generated from message depot.sandbox.v1.RemoveRequest + */ +export type RemoveRequest = Message<'depot.sandbox.v1.RemoveRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * When true, remove directories and their contents recursively (rm -rf). + * + * @generated from field: optional bool recursive = 3; + */ + recursive?: boolean + + /** + * When true, removing a path that does not exist succeeds instead of failing + * with ENOENT, matching the force option of node fs.rm. + * + * @generated from field: optional bool ignore_missing = 4; + */ + ignoreMissing?: boolean +} + +/** + * Describes the message depot.sandbox.v1.RemoveRequest. + * Use `create(RemoveRequestSchema)` to create a new message. + */ +export const RemoveRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 8, +) + +/** + * @generated from message depot.sandbox.v1.RemoveResponse + */ +export type RemoveResponse = Message<'depot.sandbox.v1.RemoveResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.RemoveResponse. + * Use `create(RemoveResponseSchema)` to create a new message. + */ +export const RemoveResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 9, +) + +/** + * @generated from message depot.sandbox.v1.RenameRequest + */ +export type RenameRequest = Message<'depot.sandbox.v1.RenameRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string from_path = 2; + */ + fromPath: string + + /** + * @generated from field: string to_path = 3; + */ + toPath: string +} + +/** + * Describes the message depot.sandbox.v1.RenameRequest. + * Use `create(RenameRequestSchema)` to create a new message. + */ +export const RenameRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 10, +) + +/** + * @generated from message depot.sandbox.v1.RenameResponse + */ +export type RenameResponse = Message<'depot.sandbox.v1.RenameResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.RenameResponse. + * Use `create(RenameResponseSchema)` to create a new message. + */ +export const RenameResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 11, +) + +/** + * @generated from message depot.sandbox.v1.CopyFileRequest + */ +export type CopyFileRequest = Message<'depot.sandbox.v1.CopyFileRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string from_path = 2; + */ + fromPath: string + + /** + * @generated from field: string to_path = 3; + */ + toPath: string + + /** + * When true, preserve the file's mode, owner, and timestamps where the server + * can (cp -p). + * + * @generated from field: optional bool preserve_metadata = 4; + */ + preserveMetadata?: boolean +} + +/** + * Describes the message depot.sandbox.v1.CopyFileRequest. + * Use `create(CopyFileRequestSchema)` to create a new message. + */ +export const CopyFileRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 12, +) + +/** + * @generated from message depot.sandbox.v1.CopyFileResponse + */ +export type CopyFileResponse = Message<'depot.sandbox.v1.CopyFileResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.CopyFileResponse. + * Use `create(CopyFileResponseSchema)` to create a new message. + */ +export const CopyFileResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 13, +) + +/** + * @generated from message depot.sandbox.v1.TruncateRequest + */ +export type TruncateRequest = Message<'depot.sandbox.v1.TruncateRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * @generated from field: int64 size = 3; + */ + size: bigint +} + +/** + * Describes the message depot.sandbox.v1.TruncateRequest. + * Use `create(TruncateRequestSchema)` to create a new message. + */ +export const TruncateRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 14, +) + +/** + * @generated from message depot.sandbox.v1.TruncateResponse + */ +export type TruncateResponse = Message<'depot.sandbox.v1.TruncateResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.TruncateResponse. + * Use `create(TruncateResponseSchema)` to create a new message. + */ +export const TruncateResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 15, +) + +/** + * @generated from message depot.sandbox.v1.ChmodRequest + */ +export type ChmodRequest = Message<'depot.sandbox.v1.ChmodRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * The new permission mode as an octal integer. The SDK accepts either a + * string ("755") or a number (0o755) and converts it. Required: presence is + * tracked (optional) so the server rejects an omitted mode with + * InvalidArgument instead of silently applying 000, the proto3 zero value. + * Matches MkdirRequest.mode and WriteFileRequest.Init.mode, which are also + * presence-tracked. + * + * @generated from field: optional uint32 mode = 3; + */ + mode?: number +} + +/** + * Describes the message depot.sandbox.v1.ChmodRequest. + * Use `create(ChmodRequestSchema)` to create a new message. + */ +export const ChmodRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 16, +) + +/** + * @generated from message depot.sandbox.v1.ChmodResponse + */ +export type ChmodResponse = Message<'depot.sandbox.v1.ChmodResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.ChmodResponse. + * Use `create(ChmodResponseSchema)` to create a new message. + */ +export const ChmodResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 17, +) + +/** + * @generated from message depot.sandbox.v1.ChownRequest + */ +export type ChownRequest = Message<'depot.sandbox.v1.ChownRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * @generated from field: optional uint32 uid = 3; + */ + uid?: number + + /** + * @generated from field: optional uint32 gid = 4; + */ + gid?: number +} + +/** + * Describes the message depot.sandbox.v1.ChownRequest. + * Use `create(ChownRequestSchema)` to create a new message. + */ +export const ChownRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 18, +) + +/** + * @generated from message depot.sandbox.v1.ChownResponse + */ +export type ChownResponse = Message<'depot.sandbox.v1.ChownResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.ChownResponse. + * Use `create(ChownResponseSchema)` to create a new message. + */ +export const ChownResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 19, +) + +/** + * @generated from message depot.sandbox.v1.SymlinkRequest + */ +export type SymlinkRequest = Message<'depot.sandbox.v1.SymlinkRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * Create link_path as a symlink pointing at target (ln -s target link_path). + * + * @generated from field: string target = 2; + */ + target: string + + /** + * @generated from field: string link_path = 3; + */ + linkPath: string +} + +/** + * Describes the message depot.sandbox.v1.SymlinkRequest. + * Use `create(SymlinkRequestSchema)` to create a new message. + */ +export const SymlinkRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 20, +) + +/** + * @generated from message depot.sandbox.v1.SymlinkResponse + */ +export type SymlinkResponse = Message<'depot.sandbox.v1.SymlinkResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.SymlinkResponse. + * Use `create(SymlinkResponseSchema)` to create a new message. + */ +export const SymlinkResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 21, +) + +/** + * @generated from message depot.sandbox.v1.ReadlinkRequest + */ +export type ReadlinkRequest = Message<'depot.sandbox.v1.ReadlinkRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string +} + +/** + * Describes the message depot.sandbox.v1.ReadlinkRequest. + * Use `create(ReadlinkRequestSchema)` to create a new message. + */ +export const ReadlinkRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 22, +) + +/** + * @generated from message depot.sandbox.v1.ReadlinkResponse + */ +export type ReadlinkResponse = Message<'depot.sandbox.v1.ReadlinkResponse'> & { + /** + * @generated from field: string target = 1; + */ + target: string +} + +/** + * Describes the message depot.sandbox.v1.ReadlinkResponse. + * Use `create(ReadlinkResponseSchema)` to create a new message. + */ +export const ReadlinkResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 23, +) + +/** + * @generated from message depot.sandbox.v1.AccessRequest + */ +export type AccessRequest = Message<'depot.sandbox.v1.AccessRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * Bitmask of access checks to perform (F_OK, R_OK, W_OK, X_OK). When unset, + * the server checks for existence (F_OK). + * + * @generated from field: optional uint32 mode = 3; + */ + mode?: number +} + +/** + * Describes the message depot.sandbox.v1.AccessRequest. + * Use `create(AccessRequestSchema)` to create a new message. + */ +export const AccessRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 24, +) + +/** + * @generated from message depot.sandbox.v1.AccessResponse + */ +export type AccessResponse = Message<'depot.sandbox.v1.AccessResponse'> & {} + +/** + * Describes the message depot.sandbox.v1.AccessResponse. + * Use `create(AccessResponseSchema)` to create a new message. + */ +export const AccessResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 25, +) + +/** + * @generated from message depot.sandbox.v1.ReadFileRequest + */ +export type ReadFileRequest = Message<'depot.sandbox.v1.ReadFileRequest'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string +} + +/** + * Describes the message depot.sandbox.v1.ReadFileRequest. + * Use `create(ReadFileRequestSchema)` to create a new message. + */ +export const ReadFileRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 26, +) + +/** + * One chunk of a file streamed by ReadFile. Chunks arrive in order. The final + * chunk may carry eof = true; the stream closing also signals the end of file. + * + * @generated from message depot.sandbox.v1.FileChunk + */ +export type FileChunk = Message<'depot.sandbox.v1.FileChunk'> & { + /** + * @generated from field: bytes data = 1; + */ + data: Uint8Array + + /** + * @generated from field: optional bool eof = 2; + */ + eof?: boolean +} + +/** + * Describes the message depot.sandbox.v1.FileChunk. + * Use `create(FileChunkSchema)` to create a new message. + */ +export const FileChunkSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_filesystem, 27) + +/** + * A message in the WriteFile request stream. The first message must carry init; + * every message after it carries data bytes. In init, append selects append + * semantics, and mode is applied if the file is created. + * + * @generated from message depot.sandbox.v1.WriteFileRequest + */ +export type WriteFileRequest = Message<'depot.sandbox.v1.WriteFileRequest'> & { + /** + * @generated from oneof depot.sandbox.v1.WriteFileRequest.input + */ + input: + | { + /** + * @generated from field: depot.sandbox.v1.WriteFileRequest.Init init = 1; + */ + value: WriteFileRequest_Init + case: 'init' + } + | { + /** + * @generated from field: bytes data = 2; + */ + value: Uint8Array + case: 'data' + } + | {case: undefined; value?: undefined} +} + +/** + * Describes the message depot.sandbox.v1.WriteFileRequest. + * Use `create(WriteFileRequestSchema)` to create a new message. + */ +export const WriteFileRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 28, +) + +/** + * @generated from message depot.sandbox.v1.WriteFileRequest.Init + */ +export type WriteFileRequest_Init = Message<'depot.sandbox.v1.WriteFileRequest.Init'> & { + /** + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * @generated from field: string path = 2; + */ + path: string + + /** + * @generated from field: optional uint32 mode = 3; + */ + mode?: number + + /** + * @generated from field: optional bool append = 4; + */ + append?: boolean + + /** + * When true, create the parent directories before opening the file. + * + * @generated from field: optional bool create_directories = 5; + */ + createDirectories?: boolean +} + +/** + * Describes the message depot.sandbox.v1.WriteFileRequest.Init. + * Use `create(WriteFileRequest_InitSchema)` to create a new message. + */ +export const WriteFileRequest_InitSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 28, + 0, +) + +/** + * @generated from message depot.sandbox.v1.WriteFileResponse + */ +export type WriteFileResponse = Message<'depot.sandbox.v1.WriteFileResponse'> & { + /** + * @generated from field: int64 bytes_written = 1; + */ + bytesWritten: bigint +} + +/** + * Describes the message depot.sandbox.v1.WriteFileResponse. + * Use `create(WriteFileResponseSchema)` to create a new message. + */ +export const WriteFileResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_filesystem, + 29, +) + +/** + * File system error codes, carried on a FileSystemErrorDetail. The value of + * each code IS its Linux errno number, so the client derives the negated errno + * by negating the value, with no hand-maintained code<->errno table on either + * side: a native (real-errno) backing maps its errno straight onto the wire, + * and the SDK reads code and errno back off the generated enum. The set covers + * the errnos a file-system syscall can realistically return; a code outside it + * (including one a newer peer adds that an older build doesn't know) reads as + * OTHER, so the contract degrades safely instead of mis-mapping. + * + * UNSPECIFIED (0) is the proto3 zero value and is never sent for a real + * failure. OTHER is a synthetic sentinel above the errno range: it has no errno + * (the SDK reports -1) and is the catch-all when the failure can't be + * classified into a specific code. + * + * Aliased Linux errnos are represented by a single canonical name (EAGAIN, not + * EWOULDBLOCK; EOPNOTSUPP, not ENOTSUP) so each value stays unique. + * + * @generated from enum depot.sandbox.v1.FileSystemErrorCode + */ +export enum FileSystemErrorCode { + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_UNSPECIFIED = 0; + */ + FILESYSTEM_ERROR_CODE_UNSPECIFIED = 0, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EPERM = 1; + */ + FILESYSTEM_ERROR_CODE_EPERM = 1, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENOENT = 2; + */ + FILESYSTEM_ERROR_CODE_ENOENT = 2, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EINTR = 4; + */ + FILESYSTEM_ERROR_CODE_EINTR = 4, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EIO = 5; + */ + FILESYSTEM_ERROR_CODE_EIO = 5, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EBADF = 9; + */ + FILESYSTEM_ERROR_CODE_EBADF = 9, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENOMEM = 12; + */ + FILESYSTEM_ERROR_CODE_ENOMEM = 12, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EACCES = 13; + */ + FILESYSTEM_ERROR_CODE_EACCES = 13, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EBUSY = 16; + */ + FILESYSTEM_ERROR_CODE_EBUSY = 16, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EEXIST = 17; + */ + FILESYSTEM_ERROR_CODE_EEXIST = 17, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EXDEV = 18; + */ + FILESYSTEM_ERROR_CODE_EXDEV = 18, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENOTDIR = 20; + */ + FILESYSTEM_ERROR_CODE_ENOTDIR = 20, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EISDIR = 21; + */ + FILESYSTEM_ERROR_CODE_EISDIR = 21, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EINVAL = 22; + */ + FILESYSTEM_ERROR_CODE_EINVAL = 22, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENFILE = 23; + */ + FILESYSTEM_ERROR_CODE_ENFILE = 23, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EMFILE = 24; + */ + FILESYSTEM_ERROR_CODE_EMFILE = 24, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ETXTBSY = 26; + */ + FILESYSTEM_ERROR_CODE_ETXTBSY = 26, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EFBIG = 27; + */ + FILESYSTEM_ERROR_CODE_EFBIG = 27, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENOSPC = 28; + */ + FILESYSTEM_ERROR_CODE_ENOSPC = 28, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EROFS = 30; + */ + FILESYSTEM_ERROR_CODE_EROFS = 30, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EMLINK = 31; + */ + FILESYSTEM_ERROR_CODE_EMLINK = 31, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENAMETOOLONG = 36; + */ + FILESYSTEM_ERROR_CODE_ENAMETOOLONG = 36, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ENOTEMPTY = 39; + */ + FILESYSTEM_ERROR_CODE_ENOTEMPTY = 39, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_ELOOP = 40; + */ + FILESYSTEM_ERROR_CODE_ELOOP = 40, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EOPNOTSUPP = 95; + */ + FILESYSTEM_ERROR_CODE_EOPNOTSUPP = 95, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_EDQUOT = 122; + */ + FILESYSTEM_ERROR_CODE_EDQUOT = 122, + + /** + * @generated from enum value: FILESYSTEM_ERROR_CODE_OTHER = 4096; + */ + FILESYSTEM_ERROR_CODE_OTHER = 4096, +} + +/** + * Describes the enum depot.sandbox.v1.FileSystemErrorCode. + */ +export const FileSystemErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc( + file_depot_sandbox_v1_filesystem, + 0, +) + +/** + * The kind of file returned by Stat. The SDK uses it to back the isDirectory, + * isFile, and similar checks on a node:fs/promises-style Stats object. + * + * @generated from enum depot.sandbox.v1.FileType + */ +export enum FileType { + /** + * @generated from enum value: FILE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FILE_TYPE_FILE = 1; + */ + FILE = 1, + + /** + * @generated from enum value: FILE_TYPE_DIRECTORY = 2; + */ + DIRECTORY = 2, + + /** + * @generated from enum value: FILE_TYPE_SYMLINK = 3; + */ + SYMLINK = 3, + + /** + * @generated from enum value: FILE_TYPE_BLOCK_DEVICE = 4; + */ + BLOCK_DEVICE = 4, + + /** + * @generated from enum value: FILE_TYPE_CHARACTER_DEVICE = 5; + */ + CHARACTER_DEVICE = 5, + + /** + * @generated from enum value: FILE_TYPE_FIFO = 6; + */ + FIFO = 6, + + /** + * @generated from enum value: FILE_TYPE_SOCKET = 7; + */ + SOCKET = 7, +} + +/** + * Describes the enum depot.sandbox.v1.FileType. + */ +export const FileTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_depot_sandbox_v1_filesystem, 1) diff --git a/packages/sandbox/src/gen/depot/sandbox/v1/refs_pb.ts b/packages/sandbox/src/gen/depot/sandbox/v1/refs_pb.ts new file mode 100644 index 0000000..f5a01d6 --- /dev/null +++ b/packages/sandbox/src/gen/depot/sandbox/v1/refs_pb.ts @@ -0,0 +1,46 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=js" +// @generated from file depot/sandbox/v1/refs.proto (package depot.sandbox.v1, syntax proto3) +/* eslint-disable */ + +import type {Message} from '@bufbuild/protobuf' +import type {GenFile, GenMessage} from '@bufbuild/protobuf/codegenv2' +import {fileDesc, messageDesc} from '@bufbuild/protobuf/codegenv2' + +/** + * Describes the file depot/sandbox/v1/refs.proto. + */ +export const file_depot_sandbox_v1_refs: GenFile = /*@__PURE__*/ fileDesc( + 'ChtkZXBvdC9zYW5kYm94L3YxL3JlZnMucHJvdG8SEGRlcG90LnNhbmRib3gudjEiJgoKU2FuZGJveFJlZhIMCgJpZBgBIAEoCUgAQgoKCHNlbGVjdG9yYgZwcm90bzM', +) + +/** + * Names a single sandbox. Today the only way to name one is by its id, but + * the selector oneof leaves room to add other forms later without changing + * how existing references are encoded. + * + * @generated from message depot.sandbox.v1.SandboxRef + */ +export type SandboxRef = Message<'depot.sandbox.v1.SandboxRef'> & { + /** + * @generated from oneof depot.sandbox.v1.SandboxRef.selector + */ + selector: + | { + /** + * Server-assigned identifier for the sandbox, as returned by `CreateSandbox`, + * `GetSandbox`, and `ListSandboxes`. This is the same value carried in the + * `sandboxId` field of a Sandbox. + * + * @generated from field: string id = 1; + */ + value: string + case: 'id' + } + | {case: undefined; value?: undefined} +} + +/** + * Describes the message depot.sandbox.v1.SandboxRef. + * Use `create(SandboxRefSchema)` to create a new message. + */ +export const SandboxRefSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_refs, 0) diff --git a/packages/sandbox/src/gen/depot/sandbox/v1/sandbox_pb.ts b/packages/sandbox/src/gen/depot/sandbox/v1/sandbox_pb.ts new file mode 100644 index 0000000..17dfb8c --- /dev/null +++ b/packages/sandbox/src/gen/depot/sandbox/v1/sandbox_pb.ts @@ -0,0 +1,893 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=js" +// @generated from file depot/sandbox/v1/sandbox.proto (package depot.sandbox.v1, syntax proto3) +/* eslint-disable */ + +import type {Message} from '@bufbuild/protobuf' +import type {GenEnum, GenFile, GenMessage, GenService} from '@bufbuild/protobuf/codegenv2' +import {enumDesc, fileDesc, messageDesc, serviceDesc} from '@bufbuild/protobuf/codegenv2' +import type {Timestamp} from '@bufbuild/protobuf/wkt' +import {file_google_protobuf_timestamp} from '@bufbuild/protobuf/wkt' +import type {RunCommandRequestSchema, SandboxCommandExecutionEventSchema} from './command_pb.js' +import {file_depot_sandbox_v1_command} from './command_pb.js' +import type { + AccessRequestSchema, + AccessResponseSchema, + ChmodRequestSchema, + ChmodResponseSchema, + ChownRequestSchema, + ChownResponseSchema, + CopyFileRequestSchema, + CopyFileResponseSchema, + FileChunkSchema, + MkdirRequestSchema, + MkdirResponseSchema, + ReadDirRequestSchema, + ReadDirResponseSchema, + ReadFileRequestSchema, + ReadlinkRequestSchema, + ReadlinkResponseSchema, + RemoveRequestSchema, + RemoveResponseSchema, + RenameRequestSchema, + RenameResponseSchema, + StatRequestSchema, + StatResponseSchema, + SymlinkRequestSchema, + SymlinkResponseSchema, + TruncateRequestSchema, + TruncateResponseSchema, + WriteFileRequestSchema, + WriteFileResponseSchema, +} from './filesystem_pb.js' +import {file_depot_sandbox_v1_filesystem} from './filesystem_pb.js' +import type {SandboxRef, SandboxRefSchema} from './refs_pb.js' +import {file_depot_sandbox_v1_refs} from './refs_pb.js' + +/** + * Describes the file depot/sandbox/v1/sandbox.proto. + */ +export const file_depot_sandbox_v1_sandbox: GenFile = /*@__PURE__*/ fileDesc( + 'Ch5kZXBvdC9zYW5kYm94L3YxL3NhbmRib3gucHJvdG8SEGRlcG90LnNhbmRib3gudjEicQoJUmVzb3VyY2VzEhIKBXZjcHVzGAEgASgFSACIAQESFgoJbWVtb3J5X21iGAIgASgFSAGIAQESFAoHZGlza19nYhgDIAEoBUgCiAEBQggKBl92Y3B1c0IMCgpfbWVtb3J5X21iQgoKCF9kaXNrX2diIjoKB1J1bnRpbWUSDwoFbmFtZWQYASABKAlIABITCglpbWFnZV9yZWYYAiABKAlIAEIJCgdydW50aW1lItYGCgdTYW5kYm94EhIKCnNhbmRib3hfaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEi8KBnN0YXR1cxgDIAEoDjIfLmRlcG90LnNhbmRib3gudjEuU2FuZGJveFN0YXR1cxIuCgpjcmVhdGVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIzCgpzdGFydGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgAiAEBEjMKCnN0b3BwZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAGIAQESMwoKZXhwaXJlc19hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAogBARIhChR0aW1lb3V0X21zX3JlbWFpbmluZxgIIAEoA0gDiAEBEiAKE2FjdGl2ZV9jcHVfdXNhZ2VfbXMYCSABKANIBIgBARI6Cg1uZXR3b3JrX3VzYWdlGAogASgLMh4uZGVwb3Quc2FuZGJveC52MS5OZXR3b3JrVXNhZ2VIBYgBARIuCglyZXNvdXJjZXMYCyABKAsyGy5kZXBvdC5zYW5kYm94LnYxLlJlc291cmNlcxIqCgdydW50aW1lGAwgASgLMhkuZGVwb3Quc2FuZGJveC52MS5SdW50aW1lEhYKCWV4aXRfY29kZRgOIAEoBUgGiAEBEhoKDWVycm9yX21lc3NhZ2UYDyABKAlIB4gBARIvCgNlbnYYECADKAsyIi5kZXBvdC5zYW5kYm94LnYxLlNhbmRib3guRW52RW50cnkSEQoEbmFtZRgRIAEoCUgIiAEBGioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAFCDQoLX3N0YXJ0ZWRfYXRCDQoLX3N0b3BwZWRfYXRCDQoLX2V4cGlyZXNfYXRCFwoVX3RpbWVvdXRfbXNfcmVtYWluaW5nQhYKFF9hY3RpdmVfY3B1X3VzYWdlX21zQhAKDl9uZXR3b3JrX3VzYWdlQgwKCl9leGl0X2NvZGVCEAoOX2Vycm9yX21lc3NhZ2VCBwoFX25hbWVKBAgNEA4iOwoMTmV0d29ya1VzYWdlEhUKDWluZ3Jlc3NfYnl0ZXMYASABKAMSFAoMZWdyZXNzX2J5dGVzGAIgASgDIr4CChRDcmVhdGVTYW5kYm94UmVxdWVzdBIRCgRuYW1lGAEgASgJSACIAQESMwoJcmVzb3VyY2VzGAIgASgLMhsuZGVwb3Quc2FuZGJveC52MS5SZXNvdXJjZXNIAYgBARIvCgdydW50aW1lGAMgASgLMhkuZGVwb3Quc2FuZGJveC52MS5SdW50aW1lSAKIAQESPAoDZW52GAQgAygLMi8uZGVwb3Quc2FuZGJveC52MS5DcmVhdGVTYW5kYm94UmVxdWVzdC5FbnZFbnRyeRIUCgdzdGFnaW5nGAUgASgISAOIAQEaKgoIRW52RW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIHCgVfbmFtZUIMCgpfcmVzb3VyY2VzQgoKCF9ydW50aW1lQgoKCF9zdGFnaW5nIkMKFUNyZWF0ZVNhbmRib3hSZXNwb25zZRIqCgdzYW5kYm94GAEgASgLMhkuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94IkAKEkdldFNhbmRib3hSZXNwb25zZRIqCgdzYW5kYm94GAEgASgLMhkuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94IoUDChRMaXN0U2FuZGJveGVzUmVxdWVzdBIWCglwYWdlX3NpemUYASABKAVIAIgBARIXCgpwYWdlX3Rva2VuGAIgASgJSAGIAQESQgoGZmlsdGVyGAMgASgLMi0uZGVwb3Quc2FuZGJveC52MS5MaXN0U2FuZGJveGVzUmVxdWVzdC5GaWx0ZXJIAogBARrPAQoGRmlsdGVyEi8KBnN0YXRlcxgBIAMoDjIfLmRlcG90LnNhbmRib3gudjEuU2FuZGJveFN0YXR1cxI2Cg1jcmVhdGVkX2FmdGVyGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgAiAEBEjcKDmNyZWF0ZWRfYmVmb3JlGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgBiAEBQhAKDl9jcmVhdGVkX2FmdGVyQhEKD19jcmVhdGVkX2JlZm9yZUIMCgpfcGFnZV9zaXplQg0KC19wYWdlX3Rva2VuQgkKB19maWx0ZXIidwoVTGlzdFNhbmRib3hlc1Jlc3BvbnNlEiwKCXNhbmRib3hlcxgBIAMoCzIZLmRlcG90LnNhbmRib3gudjEuU2FuZGJveBIcCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAlIAIgBAUISChBfbmV4dF9wYWdlX3Rva2VuImcKElN0b3BTYW5kYm94UmVxdWVzdBItCgdzYW5kYm94GAEgASgLMhwuZGVwb3Quc2FuZGJveC52MS5TYW5kYm94UmVmEhUKCGJsb2NraW5nGAIgASgISACIAQFCCwoJX2Jsb2NraW5nIkEKE1N0b3BTYW5kYm94UmVzcG9uc2USKgoHc2FuZGJveBgBIAEoCzIZLmRlcG90LnNhbmRib3gudjEuU2FuZGJveCJjChJLaWxsU2FuZGJveFJlcXVlc3QSLQoHc2FuZGJveBgBIAEoCzIcLmRlcG90LnNhbmRib3gudjEuU2FuZGJveFJlZhITCgZzaWduYWwYAiABKAlIAIgBAUIJCgdfc2lnbmFsIkEKE0tpbGxTYW5kYm94UmVzcG9uc2USKgoHc2FuZGJveBgBIAEoCzIZLmRlcG90LnNhbmRib3gudjEuU2FuZGJveCr3AQoNU2FuZGJveFN0YXR1cxIeChpTQU5EQk9YX1NUQVRVU19VTlNQRUNJRklFRBAAEhoKFlNBTkRCT1hfU1RBVFVTX0NSRUFURUQQARIbChdTQU5EQk9YX1NUQVRVU19BU1NJR05FRBACEhsKF1NBTkRCT1hfU1RBVFVTX1NUQVJUSU5HEAMSGgoWU0FOREJPWF9TVEFUVVNfUlVOTklORxAEEhsKF1NBTkRCT1hfU1RBVFVTX0ZJTklTSEVEEAUSHAoYU0FOREJPWF9TVEFUVVNfQ0FOQ0VMTEVEEAYSGQoVU0FOREJPWF9TVEFUVVNfRkFJTEVEEAcyjg0KDlNhbmRib3hTZXJ2aWNlEmAKDUNyZWF0ZVNhbmRib3gSJi5kZXBvdC5zYW5kYm94LnYxLkNyZWF0ZVNhbmRib3hSZXF1ZXN0GicuZGVwb3Quc2FuZGJveC52MS5DcmVhdGVTYW5kYm94UmVzcG9uc2USUAoKR2V0U2FuZGJveBIcLmRlcG90LnNhbmRib3gudjEuU2FuZGJveFJlZhokLmRlcG90LnNhbmRib3gudjEuR2V0U2FuZGJveFJlc3BvbnNlEmAKDUxpc3RTYW5kYm94ZXMSJi5kZXBvdC5zYW5kYm94LnYxLkxpc3RTYW5kYm94ZXNSZXF1ZXN0GicuZGVwb3Quc2FuZGJveC52MS5MaXN0U2FuZGJveGVzUmVzcG9uc2USWgoLU3RvcFNhbmRib3gSJC5kZXBvdC5zYW5kYm94LnYxLlN0b3BTYW5kYm94UmVxdWVzdBolLmRlcG90LnNhbmRib3gudjEuU3RvcFNhbmRib3hSZXNwb25zZRJaCgtLaWxsU2FuZGJveBIkLmRlcG90LnNhbmRib3gudjEuS2lsbFNhbmRib3hSZXF1ZXN0GiUuZGVwb3Quc2FuZGJveC52MS5LaWxsU2FuZGJveFJlc3BvbnNlEmMKClJ1bkNvbW1hbmQSIy5kZXBvdC5zYW5kYm94LnYxLlJ1bkNvbW1hbmRSZXF1ZXN0Gi4uZGVwb3Quc2FuZGJveC52MS5TYW5kYm94Q29tbWFuZEV4ZWN1dGlvbkV2ZW50MAESSAoFTWtkaXISHi5kZXBvdC5zYW5kYm94LnYxLk1rZGlyUmVxdWVzdBofLmRlcG90LnNhbmRib3gudjEuTWtkaXJSZXNwb25zZRJFCgRTdGF0Eh0uZGVwb3Quc2FuZGJveC52MS5TdGF0UmVxdWVzdBoeLmRlcG90LnNhbmRib3gudjEuU3RhdFJlc3BvbnNlEk4KB1JlYWREaXISIC5kZXBvdC5zYW5kYm94LnYxLlJlYWREaXJSZXF1ZXN0GiEuZGVwb3Quc2FuZGJveC52MS5SZWFkRGlyUmVzcG9uc2USSwoGUmVtb3ZlEh8uZGVwb3Quc2FuZGJveC52MS5SZW1vdmVSZXF1ZXN0GiAuZGVwb3Quc2FuZGJveC52MS5SZW1vdmVSZXNwb25zZRJLCgZSZW5hbWUSHy5kZXBvdC5zYW5kYm94LnYxLlJlbmFtZVJlcXVlc3QaIC5kZXBvdC5zYW5kYm94LnYxLlJlbmFtZVJlc3BvbnNlElEKCENvcHlGaWxlEiEuZGVwb3Quc2FuZGJveC52MS5Db3B5RmlsZVJlcXVlc3QaIi5kZXBvdC5zYW5kYm94LnYxLkNvcHlGaWxlUmVzcG9uc2USUQoIVHJ1bmNhdGUSIS5kZXBvdC5zYW5kYm94LnYxLlRydW5jYXRlUmVxdWVzdBoiLmRlcG90LnNhbmRib3gudjEuVHJ1bmNhdGVSZXNwb25zZRJICgVDaG1vZBIeLmRlcG90LnNhbmRib3gudjEuQ2htb2RSZXF1ZXN0Gh8uZGVwb3Quc2FuZGJveC52MS5DaG1vZFJlc3BvbnNlEkgKBUNob3duEh4uZGVwb3Quc2FuZGJveC52MS5DaG93blJlcXVlc3QaHy5kZXBvdC5zYW5kYm94LnYxLkNob3duUmVzcG9uc2USTgoHU3ltbGluaxIgLmRlcG90LnNhbmRib3gudjEuU3ltbGlua1JlcXVlc3QaIS5kZXBvdC5zYW5kYm94LnYxLlN5bWxpbmtSZXNwb25zZRJRCghSZWFkbGluaxIhLmRlcG90LnNhbmRib3gudjEuUmVhZGxpbmtSZXF1ZXN0GiIuZGVwb3Quc2FuZGJveC52MS5SZWFkbGlua1Jlc3BvbnNlEksKBkFjY2VzcxIfLmRlcG90LnNhbmRib3gudjEuQWNjZXNzUmVxdWVzdBogLmRlcG90LnNhbmRib3gudjEuQWNjZXNzUmVzcG9uc2USTAoIUmVhZEZpbGUSIS5kZXBvdC5zYW5kYm94LnYxLlJlYWRGaWxlUmVxdWVzdBobLmRlcG90LnNhbmRib3gudjEuRmlsZUNodW5rMAESVgoJV3JpdGVGaWxlEiIuZGVwb3Quc2FuZGJveC52MS5Xcml0ZUZpbGVSZXF1ZXN0GiMuZGVwb3Quc2FuZGJveC52MS5Xcml0ZUZpbGVSZXNwb25zZSgBYgZwcm90bzM', + [ + file_depot_sandbox_v1_command, + file_depot_sandbox_v1_filesystem, + file_depot_sandbox_v1_refs, + file_google_protobuf_timestamp, + ], +) + +/** + * Compute resources for a sandbox. Every field is optional; leave a field unset + * to take the server's default for that dimension. The defaults are currently + * chosen by the server and not pinned here. + * + * @generated from message depot.sandbox.v1.Resources + */ +export type Resources = Message<'depot.sandbox.v1.Resources'> & { + /** + * Number of virtual CPUs. Leave unset to take the server default. + * + * @generated from field: optional int32 vcpus = 1; + */ + vcpus?: number + + /** + * Memory in megabytes. Leave unset to take the server default. + * + * @generated from field: optional int32 memory_mb = 2; + */ + memoryMb?: number + + /** + * Disk size in gigabytes. Leave unset to take the server default. + * + * @generated from field: optional int32 disk_gb = 3; + */ + diskGb?: number +} + +/** + * Describes the message depot.sandbox.v1.Resources. + * Use `create(ResourcesSchema)` to create a new message. + */ +export const ResourcesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_sandbox, 0) + +/** + * Chooses the runtime a sandbox boots into. Set `imageRef` to an OCI image + * reference. `named` is reserved for a future catalog of curated runtimes + * (for example "node24") resolved by the server; the server rejects any + * `named` value with InvalidArgument today, so set `imageRef` instead. + * + * @generated from message depot.sandbox.v1.Runtime + */ +export type Runtime = Message<'depot.sandbox.v1.Runtime'> & { + /** + * @generated from oneof depot.sandbox.v1.Runtime.runtime + */ + runtime: + | { + /** + * A curated runtime name resolved by the server. Reserved for a future + * catalog and currently rejected with InvalidArgument; use `imageRef` + * instead. + * + * @generated from field: string named = 1; + */ + value: string + case: 'named' + } + | { + /** + * An OCI image reference to boot the sandbox from. + * + * @generated from field: string image_ref = 2; + */ + value: string + case: 'imageRef' + } + | {case: undefined; value?: undefined} +} + +/** + * Describes the message depot.sandbox.v1.Runtime. + * Use `create(RuntimeSchema)` to create a new message. + */ +export const RuntimeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_sandbox, 1) + +/** + * A sandbox and its current state, as returned by the lifecycle methods. Field + * number 13 is reserved: it once held a separate termination reason, which was + * dropped because the `status` value already conveys how a sandbox ended. + * + * @generated from message depot.sandbox.v1.Sandbox + */ +export type Sandbox = Message<'depot.sandbox.v1.Sandbox'> & { + /** + * Unique identifier for the sandbox. + * + * @generated from field: string sandbox_id = 1; + */ + sandboxId: string + + /** + * Organization that owns the sandbox. + * + * @generated from field: string organization_id = 2; + */ + organizationId: string + + /** + * Current state of the sandbox in its lifecycle. + * + * @generated from field: depot.sandbox.v1.SandboxStatus status = 3; + */ + status: SandboxStatus + + /** + * Time the sandbox record was created, in RFC 3339 format. Always populated. + * + * @generated from field: google.protobuf.Timestamp created_at = 4; + */ + createdAt?: Timestamp + + /** + * Time the sandbox started running, in RFC 3339 format. Unset until the + * sandbox starts. + * + * @generated from field: optional google.protobuf.Timestamp started_at = 5; + */ + startedAt?: Timestamp + + /** + * Time the sandbox stopped, in RFC 3339 format. Unset until the sandbox + * reaches a terminal state. + * + * @generated from field: optional google.protobuf.Timestamp stopped_at = 6; + */ + stoppedAt?: Timestamp + + /** + * Time the sandbox will expire, in RFC 3339 format. This is the stored, + * authoritative deadline. Unset when the sandbox has no timeout. + * + * @generated from field: optional google.protobuf.Timestamp expires_at = 7; + */ + expiresAt?: Timestamp + + /** + * Milliseconds remaining until the sandbox expires, computed against the + * current time when the sandbox is read. Unset when the sandbox has no + * timeout. + * + * @generated from field: optional int64 timeout_ms_remaining = 8; + */ + timeoutMsRemaining?: bigint + + /** + * Active CPU time consumed by the sandbox, in milliseconds. Populated only + * after the sandbox reaches a terminal state, and only when metering data is + * available. + * + * @generated from field: optional int64 active_cpu_usage_ms = 9; + */ + activeCpuUsageMs?: bigint + + /** + * Network bytes transferred by the sandbox. Populated only after the sandbox + * reaches a terminal state, and only when metering data is available. + * + * @generated from field: optional depot.sandbox.v1.NetworkUsage network_usage = 10; + */ + networkUsage?: NetworkUsage + + /** + * The resources the server resolved for this sandbox, echoed back so callers + * can see what was actually provisioned. + * + * @generated from field: depot.sandbox.v1.Resources resources = 11; + */ + resources?: Resources + + /** + * The runtime the server resolved for this sandbox, echoed back so callers + * can see what was actually provisioned. + * + * @generated from field: depot.sandbox.v1.Runtime runtime = 12; + */ + runtime?: Runtime + + /** + * Process exit code, in the Unix range 0 to 255. Populated only when the + * sandbox reaches a terminal state. Negative values are reserved for a future + * signal-encoded representation. + * + * @generated from field: optional int32 exit_code = 14; + */ + exitCode?: number + + /** + * Human-readable description of why the sandbox failed. Populated only when + * the sandbox reaches a terminal state. + * + * @generated from field: optional string error_message = 15; + */ + errorMessage?: string + + /** + * Sandbox-level environment variables, echoed back from the create request. + * The server merges these into every command it runs in the sandbox; + * surfacing them here lets callers who fetched the sandbox via GetSandbox or + * ListSandboxes see the inherited variables without consulting the original + * create request. + * + * @generated from field: map env = 16; + */ + env: {[key: string]: string} + + /** + * The human-readable label set when the sandbox was created. Empty when the + * sandbox was created without one. Returned by GetSandbox and ListSandboxes + * so callers can show or filter on the label they chose. + * + * @generated from field: optional string name = 17; + */ + name?: string +} + +/** + * Describes the message depot.sandbox.v1.Sandbox. + * Use `create(SandboxSchema)` to create a new message. + */ +export const SandboxSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_sandbox, 2) + +/** + * Bytes transferred over the sandbox's primary network interface. Ingress and + * egress are reported separately so each direction stays visible even when one + * dominates. + * + * @generated from message depot.sandbox.v1.NetworkUsage + */ +export type NetworkUsage = Message<'depot.sandbox.v1.NetworkUsage'> & { + /** + * Bytes received by the sandbox. + * + * @generated from field: int64 ingress_bytes = 1; + */ + ingressBytes: bigint + + /** + * Bytes sent by the sandbox. + * + * @generated from field: int64 egress_bytes = 2; + */ + egressBytes: bigint +} + +/** + * Describes the message depot.sandbox.v1.NetworkUsage. + * Use `create(NetworkUsageSchema)` to create a new message. + */ +export const NetworkUsageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_depot_sandbox_v1_sandbox, 3) + +/** + * @generated from message depot.sandbox.v1.CreateSandboxRequest + */ +export type CreateSandboxRequest = Message<'depot.sandbox.v1.CreateSandboxRequest'> & { + /** + * Human-readable label for the sandbox, scoped to the organization. Optional. + * + * @generated from field: optional string name = 1; + */ + name?: string + + /** + * Requested compute resources. The server fills in defaults for any unset + * field. + * + * @generated from field: optional depot.sandbox.v1.Resources resources = 2; + */ + resources?: Resources + + /** + * Requested runtime. Set `imageRef`; `named` is reserved and rejected with + * InvalidArgument until the curated runtime catalog ships. + * + * @generated from field: optional depot.sandbox.v1.Runtime runtime = 3; + */ + runtime?: Runtime + + /** + * Environment variables to apply to the sandbox. They are stored on the + * sandbox and merged into every command run in it, with a command's own + * environment taking precedence on a key collision. Values are passed through + * verbatim, with no shell interpretation or special-file handling. + * + * @generated from field: map env = 4; + */ + env: {[key: string]: string} + + /** + * Place the sandbox on a non-production compute pool instead of + * production. This is an unstable option for Depot-supported testing and may be removed without notice. Leave unset for normal placement. + * Default: false. + * + * @generated from field: optional bool staging = 5; + */ + staging?: boolean +} + +/** + * Describes the message depot.sandbox.v1.CreateSandboxRequest. + * Use `create(CreateSandboxRequestSchema)` to create a new message. + */ +export const CreateSandboxRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 4, +) + +/** + * @generated from message depot.sandbox.v1.CreateSandboxResponse + */ +export type CreateSandboxResponse = Message<'depot.sandbox.v1.CreateSandboxResponse'> & { + /** + * The newly created sandbox. + * + * @generated from field: depot.sandbox.v1.Sandbox sandbox = 1; + */ + sandbox?: Sandbox +} + +/** + * Describes the message depot.sandbox.v1.CreateSandboxResponse. + * Use `create(CreateSandboxResponseSchema)` to create a new message. + */ +export const CreateSandboxResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 5, +) + +/** + * @generated from message depot.sandbox.v1.GetSandboxResponse + */ +export type GetSandboxResponse = Message<'depot.sandbox.v1.GetSandboxResponse'> & { + /** + * The requested sandbox. + * + * @generated from field: depot.sandbox.v1.Sandbox sandbox = 1; + */ + sandbox?: Sandbox +} + +/** + * Describes the message depot.sandbox.v1.GetSandboxResponse. + * Use `create(GetSandboxResponseSchema)` to create a new message. + */ +export const GetSandboxResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 6, +) + +/** + * Lists an organization's sandboxes, with optional filtering by status and + * creation time and token-based pagination. + * + * @generated from message depot.sandbox.v1.ListSandboxesRequest + */ +export type ListSandboxesRequest = Message<'depot.sandbox.v1.ListSandboxesRequest'> & { + /** + * Number of sandboxes to return per page. + * + * @generated from field: optional int32 page_size = 1; + */ + pageSize?: number + + /** + * Token to fetch the next page, taken from a previous response's + * `nextPageToken`. Leave empty to fetch the first page. + * + * @generated from field: optional string page_token = 2; + */ + pageToken?: string + + /** + * Criteria for narrowing the results. Leave unset to list all sandboxes. + * + * @generated from field: optional depot.sandbox.v1.ListSandboxesRequest.Filter filter = 3; + */ + filter?: ListSandboxesRequest_Filter +} + +/** + * Describes the message depot.sandbox.v1.ListSandboxesRequest. + * Use `create(ListSandboxesRequestSchema)` to create a new message. + */ +export const ListSandboxesRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 7, +) + +/** + * Criteria for narrowing the list. All set criteria must match. + * + * @generated from message depot.sandbox.v1.ListSandboxesRequest.Filter + */ +export type ListSandboxesRequest_Filter = Message<'depot.sandbox.v1.ListSandboxesRequest.Filter'> & { + /** + * An array of statuses to include. When non-empty, only sandboxes whose + * status is in this set are returned. Leave empty to include all statuses. + * + * @generated from field: repeated depot.sandbox.v1.SandboxStatus states = 1; + */ + states: SandboxStatus[] + + /** + * Include only sandboxes created at or after this time, in RFC 3339 format. + * + * @generated from field: optional google.protobuf.Timestamp created_after = 2; + */ + createdAfter?: Timestamp + + /** + * Include only sandboxes created at or before this time, in RFC 3339 format. + * + * @generated from field: optional google.protobuf.Timestamp created_before = 3; + */ + createdBefore?: Timestamp +} + +/** + * Describes the message depot.sandbox.v1.ListSandboxesRequest.Filter. + * Use `create(ListSandboxesRequest_FilterSchema)` to create a new message. + */ +export const ListSandboxesRequest_FilterSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 7, + 0, +) + +/** + * @generated from message depot.sandbox.v1.ListSandboxesResponse + */ +export type ListSandboxesResponse = Message<'depot.sandbox.v1.ListSandboxesResponse'> & { + /** + * Sandboxes matching the request, most recently created first. + * + * @generated from field: repeated depot.sandbox.v1.Sandbox sandboxes = 1; + */ + sandboxes: Sandbox[] + + /** + * Token to fetch the next page. Empty when there are no more results. + * + * @generated from field: optional string next_page_token = 2; + */ + nextPageToken?: string +} + +/** + * Describes the message depot.sandbox.v1.ListSandboxesResponse. + * Use `create(ListSandboxesResponseSchema)` to create a new message. + */ +export const ListSandboxesResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 8, +) + +/** + * Request to gracefully shut down a sandbox. The server records the request + * immediately and the sandbox proceeds toward FINISHED. By default the call + * returns as soon as the request is recorded; set `blocking` to wait for the + * sandbox to actually reach a terminal state. + * + * @generated from message depot.sandbox.v1.StopSandboxRequest + */ +export type StopSandboxRequest = Message<'depot.sandbox.v1.StopSandboxRequest'> & { + /** + * The sandbox to stop. + * + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * When true, the call waits for the sandbox to reach a terminal state, up to + * a server-side limit, before returning. When false, it returns as soon as + * the stop request is recorded. Default: false. + * + * @generated from field: optional bool blocking = 2; + */ + blocking?: boolean +} + +/** + * Describes the message depot.sandbox.v1.StopSandboxRequest. + * Use `create(StopSandboxRequestSchema)` to create a new message. + */ +export const StopSandboxRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 9, +) + +/** + * @generated from message depot.sandbox.v1.StopSandboxResponse + */ +export type StopSandboxResponse = Message<'depot.sandbox.v1.StopSandboxResponse'> & { + /** + * The sandbox after the stop request was recorded. Its status reflects the + * current state at read time, typically still RUNNING, or FINISHED if it had + * already terminated by the time the response was built. There is no distinct + * "stopping" status. + * + * @generated from field: depot.sandbox.v1.Sandbox sandbox = 1; + */ + sandbox?: Sandbox +} + +/** + * Describes the message depot.sandbox.v1.StopSandboxResponse. + * Use `create(StopSandboxResponseSchema)` to create a new message. + */ +export const StopSandboxResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 10, +) + +/** + * Request to forcibly terminate a sandbox. This is fire-and-forget: the call + * returns once the termination is requested and the sandbox proceeds toward + * CANCELLED. There is no blocking option. + * + * @generated from message depot.sandbox.v1.KillSandboxRequest + */ +export type KillSandboxRequest = Message<'depot.sandbox.v1.KillSandboxRequest'> & { + /** + * The sandbox to kill. + * + * @generated from field: depot.sandbox.v1.SandboxRef sandbox = 1; + */ + sandbox?: SandboxRef + + /** + * Signal name to send, for example "SIGKILL". Reserved for future use; the + * server currently ignores it and always issues a hard cancel. + * + * @generated from field: optional string signal = 2; + */ + signal?: string +} + +/** + * Describes the message depot.sandbox.v1.KillSandboxRequest. + * Use `create(KillSandboxRequestSchema)` to create a new message. + */ +export const KillSandboxRequestSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 11, +) + +/** + * @generated from message depot.sandbox.v1.KillSandboxResponse + */ +export type KillSandboxResponse = Message<'depot.sandbox.v1.KillSandboxResponse'> & { + /** + * The sandbox after the kill request was recorded. + * + * @generated from field: depot.sandbox.v1.Sandbox sandbox = 1; + */ + sandbox?: Sandbox +} + +/** + * Describes the message depot.sandbox.v1.KillSandboxResponse. + * Use `create(KillSandboxResponseSchema)` to create a new message. + */ +export const KillSandboxResponseSchema: GenMessage = /*@__PURE__*/ messageDesc( + file_depot_sandbox_v1_sandbox, + 12, +) + +/** + * Where a sandbox is in its lifecycle. The values track the underlying + * compute state, but this is a separate type: it can grow values the compute + * layer does not track, without coordinating with + * that schema. + * + * @generated from enum depot.sandbox.v1.SandboxStatus + */ +export enum SandboxStatus { + /** + * Default zero value, used when no status has been set. + * + * @generated from enum value: SANDBOX_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * The sandbox record exists but no compute has been assigned to it yet. + * + * @generated from enum value: SANDBOX_STATUS_CREATED = 1; + */ + CREATED = 1, + + /** + * Compute has been assigned, but the VM has not started. + * + * @generated from enum value: SANDBOX_STATUS_ASSIGNED = 2; + */ + ASSIGNED = 2, + + /** + * The VM is booting. + * + * @generated from enum value: SANDBOX_STATUS_STARTING = 3; + */ + STARTING = 3, + + /** + * The VM has booted and is ready to accept commands. + * + * @generated from enum value: SANDBOX_STATUS_RUNNING = 4; + */ + RUNNING = 4, + + /** + * The sandbox terminated cleanly, either from a graceful stop or a normal + * exit. + * + * @generated from enum value: SANDBOX_STATUS_FINISHED = 5; + */ + FINISHED = 5, + + /** + * The sandbox was forcibly terminated by a kill. + * + * @generated from enum value: SANDBOX_STATUS_CANCELLED = 6; + */ + CANCELLED = 6, + + /** + * The sandbox ended in error, for example a failed image pull, an + * out-of-memory condition, or a crash. + * + * @generated from enum value: SANDBOX_STATUS_FAILED = 7; + */ + FAILED = 7, +} + +/** + * Describes the enum depot.sandbox.v1.SandboxStatus. + */ +export const SandboxStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_depot_sandbox_v1_sandbox, 0) + +/** + * @generated from service depot.sandbox.v1.SandboxService + */ +export const SandboxService: GenService<{ + /** + * @generated from rpc depot.sandbox.v1.SandboxService.CreateSandbox + */ + createSandbox: { + methodKind: 'unary' + input: typeof CreateSandboxRequestSchema + output: typeof CreateSandboxResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.GetSandbox + */ + getSandbox: { + methodKind: 'unary' + input: typeof SandboxRefSchema + output: typeof GetSandboxResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.ListSandboxes + */ + listSandboxes: { + methodKind: 'unary' + input: typeof ListSandboxesRequestSchema + output: typeof ListSandboxesResponseSchema + } + /** + * Stop is a graceful shutdown and lands the sandbox in FINISHED; kill is a + * forced termination and lands it in CANCELLED. Calling either on a sandbox + * that is already in a terminal state returns FailedPrecondition rather than + * repeating the work. + * + * @generated from rpc depot.sandbox.v1.SandboxService.StopSandbox + */ + stopSandbox: { + methodKind: 'unary' + input: typeof StopSandboxRequestSchema + output: typeof StopSandboxResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.KillSandbox + */ + killSandbox: { + methodKind: 'unary' + input: typeof KillSandboxRequestSchema + output: typeof KillSandboxResponseSchema + } + /** + * server-streaming command execution. The response stream begins + * with a Started event and ends with Finished (or terminates with a Connect + * error). Stdout/stderr arrive on dual rails (raw bytes + * alongside the legacy string oneof). Detached mode (`detached=true`) is in + * beta: the server ends the stream right after Started and the command keeps + * running in the sandbox, but its output is not retained and can't be + * retrieved yet. Reattaching to a detached command's output via AttachCommand + * is a future API. + * + * @generated from rpc depot.sandbox.v1.SandboxService.RunCommand + */ + runCommand: { + methodKind: 'server_streaming' + input: typeof RunCommandRequestSchema + output: typeof SandboxCommandExecutionEventSchema + } + /** + * file system access. These methods give a caller a + * node:fs/promises-shaped view of the sandbox's file system. Their messages + * and enums are defined in filesystem.proto. Failures arrive as Connect + * errors with a FileSystemErrorDetail attached, which the SDK turns back into + * a Node-style error carrying code, syscall, and path. The unary methods + * cover the metadata and mutation operations; ReadFile and WriteFile stream + * file contents so that large files don't have to be buffered whole. + * + * @generated from rpc depot.sandbox.v1.SandboxService.Mkdir + */ + mkdir: { + methodKind: 'unary' + input: typeof MkdirRequestSchema + output: typeof MkdirResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Stat + */ + stat: { + methodKind: 'unary' + input: typeof StatRequestSchema + output: typeof StatResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.ReadDir + */ + readDir: { + methodKind: 'unary' + input: typeof ReadDirRequestSchema + output: typeof ReadDirResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Remove + */ + remove: { + methodKind: 'unary' + input: typeof RemoveRequestSchema + output: typeof RemoveResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Rename + */ + rename: { + methodKind: 'unary' + input: typeof RenameRequestSchema + output: typeof RenameResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.CopyFile + */ + copyFile: { + methodKind: 'unary' + input: typeof CopyFileRequestSchema + output: typeof CopyFileResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Truncate + */ + truncate: { + methodKind: 'unary' + input: typeof TruncateRequestSchema + output: typeof TruncateResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Chmod + */ + chmod: { + methodKind: 'unary' + input: typeof ChmodRequestSchema + output: typeof ChmodResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Chown + */ + chown: { + methodKind: 'unary' + input: typeof ChownRequestSchema + output: typeof ChownResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Symlink + */ + symlink: { + methodKind: 'unary' + input: typeof SymlinkRequestSchema + output: typeof SymlinkResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Readlink + */ + readlink: { + methodKind: 'unary' + input: typeof ReadlinkRequestSchema + output: typeof ReadlinkResponseSchema + } + /** + * @generated from rpc depot.sandbox.v1.SandboxService.Access + */ + access: { + methodKind: 'unary' + input: typeof AccessRequestSchema + output: typeof AccessResponseSchema + } + /** + * ReadFile streams the contents of a file as ordered FileChunk messages. + * + * @generated from rpc depot.sandbox.v1.SandboxService.ReadFile + */ + readFile: { + methodKind: 'server_streaming' + input: typeof ReadFileRequestSchema + output: typeof FileChunkSchema + } + /** + * WriteFile consumes a request stream that opens with an Init message naming + * the file, followed by the bytes to write. + * + * @generated from rpc depot.sandbox.v1.SandboxService.WriteFile + */ + writeFile: { + methodKind: 'client_streaming' + input: typeof WriteFileRequestSchema + output: typeof WriteFileResponseSchema + } +}> = /*@__PURE__*/ serviceDesc(file_depot_sandbox_v1_sandbox, 0) diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts new file mode 100644 index 0000000..e1c7a84 --- /dev/null +++ b/packages/sandbox/src/index.ts @@ -0,0 +1,44 @@ +// Public surface of @depot/sandbox. +// +// Lifecycle surface: create + get + list + listAll + stop + kill. Sandbox +// instances carry their client, so instance methods and child objects do not +// require callers to pass it again. + +export {DEFAULT_ENDPOINT, createClient, type CreateClientOpts, type SandboxClient} from './client.js' +export { + SandboxCommandExecution, + type SandboxCommandExecutionFinished, + type SandboxCommandExecutionLogChunk, +} from './command.js' +export {FileSystemError, SlowConsumerError, type FileSystemErrorCode} from './errors.js' +export { + DirEntry, + FileSystem, + StatResult, + type CopyFileOpts, + type FileTypeName, + type MkdirOpts, + type ReadFileOpts, + type ReaddirOpts, + type RmOpts, + type WriteFileOpts, +} from './filesystem.js' +export { + Sandbox, + type CreateSandboxOpts, + type KillSandboxOpts, + type ListAllSandboxesOpts, + type ListSandboxesOpts, + type ListSandboxesResult, + type StopSandboxOpts, +} from './sandbox.js' +export type { + ListFilter, + Pagination, + PaginationResult, + Resources, + RunCommandOpts, + Runtime, + SandboxCommandExecutionStatus, + SandboxStatus, +} from './types.js' diff --git a/packages/sandbox/src/k-streaming.test.ts b/packages/sandbox/src/k-streaming.test.ts new file mode 100644 index 0000000..5f71b54 --- /dev/null +++ b/packages/sandbox/src/k-streaming.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert' +import test from 'node:test' +import {SlowConsumerError} from './errors.js' +import {BufferedEventLog} from './k-streaming.js' + +test('append/iterate happy path: producer events arrive in order at the consumer', async () => { + const log = new BufferedEventLog() + const it = log.iterate() + log.append(1) + log.append(2) + log.append(3) + log.end() + const out: number[] = [] + for await (const ev of it) out.push(ev) + assert.deepEqual(out, [1, 2, 3]) +}) + +test('multi-consumer isolation: two iterators each see the full sequence', async () => { + const log = new BufferedEventLog() + const a = log.iterate() + log.append(1) + const b = log.iterate() + log.append(2) + log.append(3) + log.end() + const aOut: number[] = [] + for await (const ev of a) aOut.push(ev) + const bOut: number[] = [] + for await (const ev of b) bOut.push(ev) + assert.deepEqual(aOut, [1, 2, 3]) + assert.deepEqual(bOut, [1, 2, 3]) +}) + +test('end() drains pending events then closes', async () => { + const log = new BufferedEventLog() + const it = log.iterate() + log.append(1) + log.append(2) + log.end() + const out: number[] = [] + for await (const ev of it) out.push(ev) + assert.deepEqual(out, [1, 2]) + assert.equal(log.isClosed, true) +}) + +test('slow consumer overflow surfaces SlowConsumerError; healthy consumers are unaffected', async () => { + const log = new BufferedEventLog({consumerQueueLimit: 2}) + const slow = log.iterate() + const fast = log.iterate() + // Lockstep-drain fast so its queue never exceeds the limit; slow never + // drains so its queue overflows after the third append. + log.append(1) + await fast.next() + log.append(2) + await fast.next() + log.append(3) + await fast.next() + log.append(4) + await fast.next() + + await assert.rejects(async () => { + for await (const _ of slow) { + // pass + } + }, SlowConsumerError) +}) diff --git a/packages/sandbox/src/k-streaming.ts b/packages/sandbox/src/k-streaming.ts new file mode 100644 index 0000000..0472877 --- /dev/null +++ b/packages/sandbox/src/k-streaming.ts @@ -0,0 +1,278 @@ +// Client-side multi-consumer event log. +// +// The SDK side does not enforce a byte-cap because the server is authoritative +// for that. The SDK buffer sits between RPC arrival and consumer drain. +// +// Used by `Command` to fan out `command.logs()` consumers from a single +// RunCommand stream. + +import {SlowConsumerError} from './errors.js' + +interface EvictionInfo { + droppedBytes: number +} + +const DEFAULT_MAX_BYTES = 16 * 1024 * 1024 +const DEFAULT_IDLE_EVICT_MS = 30_000 +const DEFAULT_CONSUMER_QUEUE_LIMIT = 1024 + +interface BufferedEventLogOpts { + byteCostOf?: (event: T) => number + maxBytes?: number + idleEvictMs?: number + consumerQueueLimit?: number +} + +interface RingEntry { + event: T + byteCost: number + offset: number +} + +interface Consumer { + queue: T[] + nextOffset: number + resolve: ((value: IteratorResult) => void) | null + reject: ((err: unknown) => void) | null + overflowed: boolean + cancelled: boolean + // Number of queue entries that came from initial replay backfill (not + // live appends). The live-consumer cap measures producer-vs-consumer + // lag, so it has to ignore the replay backlog or a late subscriber to + // a long-running command tips over on the next live append before it's + // had a chance to drain. Decremented as queue entries are pulled by + // next(); once it hits zero we're in pure live-tail mode and the cap + // applies normally. + replayPending: number +} + +export class BufferedEventLog { + private readonly byteCostOf: (event: T) => number + private readonly maxBytes: number + private readonly idleEvictMs: number + private readonly consumerQueueLimit: number + + private readonly ring: RingEntry[] = [] + private head = 0 + private tail = 0 + + private retainedBytes = 0 + private droppedBytes = 0 + private closed = false + private failure: unknown = null + private lastDrainAt: number = Date.now() + + private readonly consumers = new Set>() + + constructor(opts: BufferedEventLogOpts = {}) { + this.byteCostOf = opts.byteCostOf ?? (() => 1) + this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + this.idleEvictMs = opts.idleEvictMs ?? DEFAULT_IDLE_EVICT_MS + this.consumerQueueLimit = opts.consumerQueueLimit ?? DEFAULT_CONSUMER_QUEUE_LIMIT + } + + append(event: T): void { + if (this.closed) { + throw new Error('BufferedEventLog.append() after end()/error()') + } + const byteCost = this.byteCostOf(event) + const entry: RingEntry = {event, byteCost, offset: this.tail} + this.ring.push(entry) + this.tail += 1 + this.retainedBytes += byteCost + this.evictIfNeeded() + this.fanOut(entry) + } + + end(): void { + if (this.closed) return + this.closed = true + for (const consumer of this.consumers) { + if (consumer.resolve && consumer.queue.length === 0) { + const resolve = consumer.resolve + consumer.resolve = null + consumer.reject = null + resolve({value: undefined, done: true}) + } + } + // Drop the buffer's references to all consumers. A still-live iterator + // keeps its own consumer via closure and drains its remaining queue + // (then self-detaches), so this only releases abandoned consumers that + // would otherwise pin their queued events for the buffer's lifetime. + this.consumers.clear() + } + + error(err: unknown): void { + if (this.closed) return + this.closed = true + this.failure = err + for (const consumer of this.consumers) { + if (consumer.reject) { + const reject = consumer.reject + consumer.resolve = null + consumer.reject = null + reject(err) + } + } + // Release references for the same reason as end(); a consumer that never + // drains after error() must not keep its queue/callbacks alive here. + this.consumers.clear() + } + + iterate(opts?: {fromOffset?: number; onEvicted?: (info: EvictionInfo) => void}): AsyncIterableIterator { + const requestedOffset = opts?.fromOffset ?? 0 + const onEvicted = opts?.onEvicted + + if (onEvicted && requestedOffset < this.head && this.droppedBytes > 0) { + onEvicted({droppedBytes: this.droppedBytes}) + } + + const consumer: Consumer = { + queue: [], + nextOffset: Math.max(requestedOffset, this.head), + resolve: null, + reject: null, + overflowed: false, + cancelled: false, + replayPending: 0, + } + + // Initial backfill bypasses the live-consumer queue limit. The limit + // exists to detect a *live* subscriber that can't keep up with the rail, + // not to bound the size of history a late subscriber inherits β€” both at + // attach time (a completed command's full ring being prefilled) and on + // the next live append (the cap would trip before the caller had a + // chance to drain). Track how many entries came from this backfill so + // enqueueForConsumer can ignore them when measuring producer lag. + for (const entry of this.ring) { + if (entry.offset >= consumer.nextOffset) { + consumer.queue.push(entry.event) + consumer.nextOffset = entry.offset + 1 + } + } + consumer.replayPending = consumer.queue.length + + this.consumers.add(consumer) + return this.makeIterator(consumer) + } + + get size(): number { + return this.ring.length + } + get bytesRetained(): number { + return this.retainedBytes + } + get bytesDropped(): number { + return this.droppedBytes + } + get isClosed(): boolean { + return this.closed + } + get hasFailure(): boolean { + return this.failure !== null + } + get consumerCount(): number { + return this.consumers.size + } + + private fanOut(entry: RingEntry): void { + for (const consumer of this.consumers) { + if (consumer.cancelled || consumer.overflowed) continue + if (entry.offset < consumer.nextOffset) continue + this.enqueueForConsumer(consumer, entry.event) + consumer.nextOffset = entry.offset + 1 + } + } + + private enqueueForConsumer(consumer: Consumer, event: T): void { + if (consumer.resolve) { + const resolve = consumer.resolve + consumer.resolve = null + consumer.reject = null + this.lastDrainAt = Date.now() + resolve({value: event, done: false}) + return + } + // Cap only the live tail; the replay backlog gets a free pass until + // the caller drains through it. liveQueued = total queued βˆ’ replay + // backlog still pending; once replayPending hits zero, the cap is the + // ordinary live-only limit. + const liveQueued = consumer.queue.length - consumer.replayPending + if (liveQueued >= this.consumerQueueLimit) { + consumer.overflowed = true + return + } + consumer.queue.push(event) + } + + private evictIfNeeded(): void { + const now = Date.now() + const idleExpired = this.consumers.size > 0 && now - this.lastDrainAt >= this.idleEvictMs + + while (this.ring.length > 0) { + const overByteCap = this.retainedBytes > this.maxBytes + const headOffset = this.ring[0]!.offset + const everyConsumerPast = Array.from(this.consumers).every((c) => c.nextOffset > headOffset) + if (!overByteCap && !(idleExpired && everyConsumerPast)) break + + const evicted = this.ring.shift()! + this.retainedBytes -= evicted.byteCost + this.droppedBytes += evicted.byteCost + this.head = evicted.offset + 1 + } + } + + private makeIterator(consumer: Consumer): AsyncIterableIterator { + const next = async (): Promise> => { + if (consumer.overflowed) { + this.detach(consumer) + throw new SlowConsumerError() + } + if (consumer.cancelled) { + return {value: undefined, done: true} + } + if (consumer.queue.length > 0) { + const event = consumer.queue.shift()! + if (consumer.replayPending > 0) consumer.replayPending -= 1 + this.lastDrainAt = Date.now() + return {value: event, done: false} + } + if (this.failure !== null) { + this.detach(consumer) + throw this.failure + } + if (this.closed) { + this.detach(consumer) + return {value: undefined, done: true} + } + return new Promise>((resolve, reject) => { + consumer.resolve = resolve + consumer.reject = reject + }) + } + const returnFn = async (): Promise> => { + this.detach(consumer) + return {value: undefined, done: true} + } + const throwFn = async (err: unknown): Promise> => { + this.detach(consumer) + throw err + } + const iterator: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return iterator + }, + next, + return: returnFn, + throw: throwFn, + } + return iterator + } + + private detach(consumer: Consumer): void { + consumer.cancelled = true + consumer.resolve = null + consumer.reject = null + this.consumers.delete(consumer) + } +} diff --git a/packages/sandbox/src/sandbox.test.ts b/packages/sandbox/src/sandbox.test.ts new file mode 100644 index 0000000..f71afb4 --- /dev/null +++ b/packages/sandbox/src/sandbox.test.ts @@ -0,0 +1,281 @@ +import {create, type MessageInitShape} from '@bufbuild/protobuf' +import {timestampFromDate} from '@bufbuild/protobuf/wkt' +import assert from 'node:assert' +import test from 'node:test' +import type {SandboxClient} from './client.js' +import {FileSystem} from './filesystem.js' +import { + SandboxCommandExecutionEventSchema, + SandboxCommandExecutionEvent_FinishedSchema, + SandboxCommandExecutionEvent_StartedSchema, +} from './gen/depot/sandbox/v1/command_pb.js' +import { + CreateSandboxResponseSchema, + GetSandboxResponseSchema, + ListSandboxesResponseSchema, + SandboxSchema, + SandboxStatus as SandboxStatusProto, + StopSandboxResponseSchema, + type Sandbox as SandboxProto, +} from './gen/depot/sandbox/v1/sandbox_pb.js' +import {Sandbox} from './sandbox.js' + +const CREATED_AT = new Date('2026-06-08T12:00:00.000Z') +const STARTED_AT = new Date('2026-06-08T12:00:01.000Z') +const FINISHED_AT = new Date('2026-06-08T12:00:02.000Z') + +type FakeClient = { + client: SandboxClient + calls: (name: string) => unknown[] + lastRequest: (name: string) => unknown +} + +function fakeClient(overrides: Partial unknown>>): FakeClient { + const requests = new Map() + const handler = (name: string) => { + const fn = overrides[name] + if (!fn) throw new Error(`unexpected RPC call: ${name}`) + return (req: unknown) => { + const calls = requests.get(name) ?? [] + calls.push(req) + requests.set(name, calls) + return fn(req) + } + } + const rpc = new Proxy( + {}, + { + get(_target, prop: string) { + return handler(prop) + }, + }, + ) + return { + client: {rpc, endpoint: 'http://test'} as unknown as SandboxClient, + calls: (name: string) => requests.get(name) ?? [], + lastRequest: (name: string) => { + const calls = requests.get(name) ?? [] + return calls[calls.length - 1] + }, + } +} + +function makeSandbox(overrides: MessageInitShape = {}): SandboxProto { + return create(SandboxSchema, { + sandboxId: 'sbx_1', + organizationId: 'org_1', + status: SandboxStatusProto.RUNNING, + createdAt: timestampFromDate(CREATED_AT), + env: {BASE: 'sandbox'}, + ...overrides, + }) +} + +function createResponse(sandbox: SandboxProto) { + return create(CreateSandboxResponseSchema, {sandbox}) +} + +function makeStartedEvent(cmdId: string) { + return create(SandboxCommandExecutionEventSchema, { + event: { + case: 'started', + value: create(SandboxCommandExecutionEvent_StartedSchema, {cmdId, startedAt: timestampFromDate(STARTED_AT)}), + }, + }) +} + +function makeFinishedEvent(exitCode: number) { + return create(SandboxCommandExecutionEventSchema, { + event: { + case: 'finished', + value: create(SandboxCommandExecutionEvent_FinishedSchema, { + exitCode, + finishedAt: timestampFromDate(FINISHED_AT), + }), + }, + }) +} + +test('Sandbox.create takes an explicit client and binds it to the returned sandbox', async () => { + const recording = fakeClient({ + createSandbox: () => createResponse(makeSandbox({sandboxId: 'sbx_created'})), + stopSandbox: () => + create(StopSandboxResponseSchema, { + sandbox: makeSandbox({sandboxId: 'sbx_created', status: SandboxStatusProto.FINISHED}), + }), + }) + + const sandbox = await Sandbox.create(recording.client, {name: 'explicit-client'}) + assert.equal(sandbox.sandboxId, 'sbx_created') + assert.deepEqual(recording.lastRequest('createSandbox'), { + name: 'explicit-client', + resources: undefined, + runtime: undefined, + env: undefined, + staging: undefined, + }) + + await sandbox.stop() + assert.deepEqual(recording.lastRequest('stopSandbox'), { + sandbox: {selector: {case: 'id', value: 'sbx_created'}}, + blocking: undefined, + }) +}) + +test('Sandbox.get, list, and listAll bind returned sandboxes to the resolved client', async () => { + const recording = fakeClient({ + getSandbox: () => create(GetSandboxResponseSchema, {sandbox: makeSandbox({sandboxId: 'sbx_get'})}), + listSandboxes: (req) => { + const pageToken = (req as {pageToken?: string}).pageToken + if (!pageToken) { + return create(ListSandboxesResponseSchema, { + sandboxes: [makeSandbox({sandboxId: 'sbx_a'})], + nextPageToken: 'next', + }) + } + return create(ListSandboxesResponseSchema, {sandboxes: [makeSandbox({sandboxId: 'sbx_b'})]}) + }, + stopSandbox: () => + create(StopSandboxResponseSchema, { + sandbox: makeSandbox({sandboxId: 'sbx_b', status: SandboxStatusProto.FINISHED}), + }), + }) + + const fetched = await Sandbox.get(recording.client, 'sbx_get') + assert.equal(fetched.sandboxId, 'sbx_get') + assert.deepEqual(recording.lastRequest('getSandbox'), {selector: {case: 'id', value: 'sbx_get'}}) + + const page = await Sandbox.list(recording.client, {pagination: {pageSize: 1}}) + assert.deepEqual( + page.sandboxes.map((sandbox) => sandbox.sandboxId), + ['sbx_a'], + ) + + const all: Sandbox[] = [] + for await (const sandbox of Sandbox.listAll(recording.client, {pageSize: 1})) { + all.push(sandbox) + } + assert.deepEqual( + all.map((sandbox) => sandbox.sandboxId), + ['sbx_a', 'sbx_b'], + ) + + await all[1]?.stop() + assert.deepEqual(recording.lastRequest('stopSandbox'), { + sandbox: {selector: {case: 'id', value: 'sbx_b'}}, + blocking: undefined, + }) +}) + +test('sandboxes fetched from different clients keep using their originating clients', async () => { + const clientA = fakeClient({ + listSandboxes: () => create(ListSandboxesResponseSchema, {sandboxes: [makeSandbox({sandboxId: 'sbx_a'})]}), + stopSandbox: () => + create(StopSandboxResponseSchema, { + sandbox: makeSandbox({sandboxId: 'sbx_a', status: SandboxStatusProto.FINISHED}), + }), + }) + const clientB = fakeClient({ + getSandbox: () => create(GetSandboxResponseSchema, {sandbox: makeSandbox({sandboxId: 'sbx_b'})}), + stopSandbox: () => + create(StopSandboxResponseSchema, { + sandbox: makeSandbox({sandboxId: 'sbx_b', status: SandboxStatusProto.FINISHED}), + }), + }) + + const pageA = await Sandbox.list(clientA.client) + const sandboxB = await Sandbox.get(clientB.client, 'sbx_b') + + await pageA.sandboxes[0]?.stop() + await sandboxB.stop() + + assert.equal(clientA.calls('stopSandbox').length, 1) + assert.equal(clientB.calls('stopSandbox').length, 1) + assert.deepEqual(clientA.lastRequest('stopSandbox'), { + sandbox: {selector: {case: 'id', value: 'sbx_a'}}, + blocking: undefined, + }) + assert.deepEqual(clientB.lastRequest('stopSandbox'), { + sandbox: {selector: {case: 'id', value: 'sbx_b'}}, + blocking: undefined, + }) +}) + +test('Sandbox instance methods use the client captured at creation', async () => { + async function* runCommandStream() { + yield makeStartedEvent('cmd_1') + yield makeFinishedEvent(0) + } + const recording = fakeClient({ + createSandbox: () => createResponse(makeSandbox({sandboxId: 'sbx_bound'})), + stopSandbox: () => + create(StopSandboxResponseSchema, { + sandbox: makeSandbox({sandboxId: 'sbx_bound', status: SandboxStatusProto.FINISHED}), + }), + killSandbox: () => ({sandbox: makeSandbox({sandboxId: 'sbx_bound', status: SandboxStatusProto.CANCELLED})}), + runCommand: () => runCommandStream(), + mkdir: () => ({}), + }) + + const sandbox = await Sandbox.create(recording.client, {env: {BASE: 'sandbox'}}) + + await sandbox.stop({blocking: true}) + assert.deepEqual(recording.lastRequest('stopSandbox'), { + sandbox: {selector: {case: 'id', value: 'sbx_bound'}}, + blocking: true, + }) + + await sandbox.kill({signal: 'SIGKILL'}) + assert.deepEqual(recording.lastRequest('killSandbox'), { + sandbox: {selector: {case: 'id', value: 'sbx_bound'}}, + signal: 'SIGKILL', + }) + + const command = await sandbox.runCommand({cmd: 'echo', args: ['hi'], env: {EXTRA: '1'}}) + const finished = await command.wait() + assert.equal(finished.exitCode, 0) + assert.deepEqual(recording.lastRequest('runCommand'), { + sandbox: {selector: {case: 'id', value: 'sbx_bound'}}, + cmd: 'echo', + args: ['hi'], + cwd: undefined, + env: {EXTRA: '1'}, + sudo: undefined, + detached: undefined, + }) + assert.deepEqual({...command.env}, {BASE: 'sandbox', EXTRA: '1'}) + + const fs = sandbox.fs() + assert.ok(fs instanceof FileSystem) + await fs.mkdir('/work') + assert.deepEqual(recording.lastRequest('mkdir'), { + sandbox: {selector: {case: 'id', value: 'sbx_bound'}}, + path: '/work', + recursive: undefined, + mode: undefined, + }) +}) + +function assertSandboxTypeContract(sandbox: Sandbox, client: SandboxClient): void { + void Sandbox.create(client) + void Sandbox.get(client, 'sbx_1') + void Sandbox.list(client) + void Sandbox.listAll(client) + void sandbox.stop() + void sandbox.kill() + void sandbox.runCommand({cmd: 'echo'}) + void sandbox.fs() + + // @ts-expect-error instance stop uses the bound client, not a passed client + void sandbox.stop(client) + // @ts-expect-error instance kill uses the bound client, not a passed client + void sandbox.kill(client) + // @ts-expect-error instance runCommand uses the bound client, not a passed client + void sandbox.runCommand(client, {cmd: 'echo'}) + // @ts-expect-error instance fs uses the bound client, not a passed client + void sandbox.fs(client) + // @ts-expect-error static create still requires the explicit client boundary + void Sandbox.create() +} + +void assertSandboxTypeContract diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts new file mode 100644 index 0000000..74fe9d8 --- /dev/null +++ b/packages/sandbox/src/sandbox.ts @@ -0,0 +1,608 @@ +import {create} from '@bufbuild/protobuf' +import {timestampDate, timestampFromDate, type Timestamp} from '@bufbuild/protobuf/wkt' +import {Code, ConnectError} from '@connectrpc/connect' +import type {SandboxClient} from './client.js' +import {_commandInternals, type SandboxCommandExecution} from './command.js' +import {FileSystem} from './filesystem.js' +import type {SandboxCommandExecutionEvent} from './gen/depot/sandbox/v1/command_pb.js' +import { + ListSandboxesRequest_FilterSchema, + RuntimeSchema, + SandboxStatus as SandboxStatusProto, + type ListSandboxesRequest_Filter as ListSandboxesFilterProto, + type NetworkUsage as NetworkUsageProto, + type Runtime as RuntimeProto, + type Sandbox as SandboxProto, +} from './gen/depot/sandbox/v1/sandbox_pb.js' +import type { + ListFilter, + NetworkUsage, + Pagination, + PaginationResult, + Resources, + RunCommandOpts, + Runtime, + SandboxStatus, +} from './types.js' + +/** + * A sandbox. Each instance represents one sandbox running on the server. + * + * You don't construct this directly; use {@link Sandbox.create}, + * {@link Sandbox.get}, {@link Sandbox.list}, or {@link Sandbox.listAll}. + */ +export class Sandbox { + readonly sandboxId: string + readonly organizationId: string + protected readonly client: SandboxClient + + protected _status: SandboxStatus | undefined + protected _resources: Resources | undefined + protected _runtime: Runtime | undefined + protected _createdAt: Date | undefined + protected _startedAt: Date | undefined + protected _stoppedAt: Date | undefined + protected _expiresAt: Date | undefined + protected _timeoutMsRemaining: number | undefined + protected _activeCpuUsageMs: number | undefined + protected _networkUsage: NetworkUsage | undefined + protected _exitCode: number | undefined + protected _errorMessage: string | undefined + protected _name: string | undefined + // The sandbox's environment variables, as reported by the server. The + // server merges these into every command it runs, so callers can read + // them here without holding onto the original create options. + protected _env: Record = {} + + protected constructor(opts: {client: SandboxClient; sandboxId: string; organizationId: string}) { + this.client = opts.client + this.sandboxId = opts.sandboxId + this.organizationId = opts.organizationId + } + + /** + * The sandbox's current lifecycle status. Undefined when the server hasn't + * yet assigned one. + */ + get status(): SandboxStatus | undefined { + return this._status + } + + /** The resources the server actually allocated to this sandbox. */ + get resources(): Resources | undefined { + return this._resources + } + + /** The runtime the server actually resolved for this sandbox. */ + get runtime(): Runtime | undefined { + return this._runtime + } + + /** When the sandbox was created. Always set. */ + get createdAt(): Date | undefined { + return this._createdAt + } + + /** When the sandbox finished booting and started running. */ + get startedAt(): Date | undefined { + return this._startedAt + } + + /** When the sandbox stopped, if it has reached a terminal status. */ + get stoppedAt(): Date | undefined { + return this._stoppedAt + } + + /** When the sandbox will expire, if it has a timeout. */ + get expiresAt(): Date | undefined { + return this._expiresAt + } + + /** + * Milliseconds left before the sandbox times out, measured when the server + * built this response. Undefined when the sandbox has no timeout. + */ + get timeoutMsRemaining(): number | undefined { + return this._timeoutMsRemaining + } + + /** + * Total CPU time the sandbox used, in milliseconds. Only populated once the + * sandbox has stopped, and only when the server reports metering. It stays + * undefined until server-side metering is in place. + */ + get activeCpuUsageMs(): number | undefined { + return this._activeCpuUsageMs + } + + /** + * Bytes the sandbox sent and received over the network. Populated under the + * same conditions as {@link activeCpuUsageMs}. + */ + get networkUsage(): NetworkUsage | undefined { + return this._networkUsage + } + + /** Exit code of the sandbox's main process. */ + get exitCode(): number | undefined { + return this._exitCode + } + + /** A description of the failure, when the sandbox ended with an error. */ + get errorMessage(): string | undefined { + return this._errorMessage + } + + /** The human-readable label set when the sandbox was created, if any. */ + get name(): string | undefined { + return this._name + } + + /** Create a new sandbox. */ + static async create(client: SandboxClient, opts: CreateSandboxOpts = {}): Promise { + const response = await client.rpc.createSandbox({ + name: opts.name, + resources: opts.resources, + runtime: opts.runtime !== undefined ? runtimeToProto(opts.runtime) : undefined, + env: opts.env, + staging: opts.staging, + }) + const sandbox = response.sandbox + if (!sandbox) { + throw new Error('CreateSandbox response missing `sandbox`') + } + return Sandbox.fromProto(sandbox, client) + } + + /** Fetch a sandbox by its id. */ + static async get(client: SandboxClient, sandboxId: string): Promise { + const response = await client.rpc.getSandbox({selector: {case: 'id', value: sandboxId}}) + const sandbox = response.sandbox + if (!sandbox) { + throw new Error('GetSandbox response missing `sandbox`') + } + return Sandbox.fromProto(sandbox, client) + } + + /** + * Fetch a single page of sandboxes. To page through all of them + * automatically, use {@link Sandbox.listAll}. + */ + static async list(client: SandboxClient, opts: ListSandboxesOpts = {}): Promise { + const response = await client.rpc.listSandboxes({ + pageSize: opts.pagination?.pageSize, + pageToken: opts.pagination?.pageToken, + filter: opts.filter !== undefined ? filterToProto(opts.filter) : undefined, + }) + return { + sandboxes: response.sandboxes.map((s) => Sandbox.fromProto(s, client)), + pagination: {nextPageToken: response.nextPageToken || undefined}, + } + } + + /** @internal Build a Sandbox from the proto returned by the server. */ + protected static fromProto(sandbox: SandboxProto, client: SandboxClient): Sandbox { + const instance = new Sandbox({ + client, + sandboxId: sandbox.sandboxId, + organizationId: sandbox.organizationId, + }) + instance.applyProto(sandbox) + return instance + } + + /** + * Iterate over every sandbox, fetching pages as needed. Yields one sandbox + * at a time and keeps requesting pages until the server has none left. + */ + static async *listAll(client: SandboxClient, opts: ListAllSandboxesOpts = {}): AsyncIterable { + let pageToken: string | undefined = undefined + while (true) { + const page: ListSandboxesResult = await Sandbox.list(client, { + filter: opts.filter, + pagination: {pageSize: opts.pageSize, pageToken}, + }) + for (const sandbox of page.sandboxes) yield sandbox + pageToken = page.pagination.nextPageToken + if (!pageToken) return + } + } + + /** + * Stop the sandbox gracefully. The sandbox ends in the `finished` status. + * + * With `opts.blocking` set, the server waits for the sandbox to finish + * cleaning up before it responds. By default it returns as soon as it has + * recorded the stop request, and you can poll {@link Sandbox.get} to watch + * for the status to change. + * + * This updates the instance in place from the server's response, and throws + * if the sandbox has already stopped. + */ + async stop(opts: StopSandboxOpts = {}): Promise { + const response = await this.client.rpc.stopSandbox({ + sandbox: {selector: {case: 'id', value: this.sandboxId}}, + blocking: opts.blocking, + }) + if (response.sandbox) this.applyProto(response.sandbox) + } + + /** + * Kill the sandbox immediately. The sandbox ends in the `cancelled` status. + * + * `opts.signal` is accepted but currently ignored: every kill is a hard + * cancel regardless of the signal you pass. Support for forwarding the + * signal to the sandbox is planned for a later release. + * + * Throws if the sandbox has already stopped. + */ + async kill(opts: KillSandboxOpts = {}): Promise { + const response = await this.client.rpc.killSandbox({ + sandbox: {selector: {case: 'id', value: this.sandboxId}}, + signal: opts.signal, + }) + if (response.sandbox) this.applyProto(response.sandbox) + } + + /** + * Run a command in this sandbox. This is a server-streaming call, and it + * returns the {@link SandboxCommandExecution} as soon as the server reports + * that the command has started, rather than waiting for it to finish. That + * lets you attach `.logs()`/`.output()` iterators and watch output while the + * command is still running. Call `.wait()` on the returned execution to get + * the settled {@link SandboxCommandExecutionFinished} view: + * + * ```ts + * const cmd = await sandbox.runCommand({cmd: 'echo hi'}) + * const result = await cmd.wait() + * console.log(result.exitCode, await cmd.output()) + * ``` + * + * Returning early is what makes concurrent log streaming possible. The + * stream is buffered with a bounded amount of memory, so if we waited for + * the command to finish before handing control back, the start of the + * output could be discarded before any consumer had a chance to read it. + * Internally we open the stream, build the execution from the start event, + * then drain the rest of the stream into that buffer in the background; + * concurrent `.logs()` callers each get an independent iterator backed by + * the shared buffer. + * + * Detached mode is in beta. When `opts.detached` is set, the command runs + * fire-and-forget: this call returns as soon as the server reports it + * started, and the command keeps running in the sandbox afterward. Its output + * is not retained and cannot be retrieved yet (reattach / log replay is a + * future API), so `logs()`, `output()`, and `wait()` on the returned + * execution throw, and the SDK logs a warning when detached is used. + */ + async runCommand(opts: RunCommandOpts): Promise { + if (opts.detached) { + // Detached is beta and one-way: we launch the command and return, but its + // output is never streamed or stored, so there is nothing for + // logs()/output()/wait() to read. Warn rather than reject so callers can + // start using it, but make the limitation impossible to miss. + console.warn( + '[depot/sandbox] detached commands are in beta: output is not retained and cannot be retrieved yet; ' + + 'logs(), output(), and wait() are unavailable on the returned command', + ) + } + const stream = this.client.rpc.runCommand({ + sandbox: {selector: {case: 'id', value: this.sandboxId}}, + cmd: opts.cmd, + args: opts.args ?? [], + cwd: opts.cwd, + env: opts.env ?? {}, + sudo: opts.sudo, + detached: opts.detached, + }) + + // Pull the first event; it MUST be Started so the SDK can build the + // SandboxCommandExecution instance with the server-minted cmd_id. Anything else + // (including a missing oneof case) is a protocol violation. We don't + // use for-await-of here because we hand the iterator off to + // drainStream once Started lands; the cost is that the implicit + // cleanup for-await-of provides doesn't apply, so any early-exit + // path has to call iterator.return() explicitly or the underlying + // HTTP/2 stream stays open until GC. + const iterator = stream[Symbol.asyncIterator]() + let startedEvent: SandboxCommandExecutionEvent + try { + const first = await iterator.next() + if (first.done) { + throw new ConnectError('RunCommand stream closed before Started event', Code.Internal) + } + startedEvent = first.value + if (startedEvent.event.case !== 'started') { + throw new ConnectError( + `RunCommand stream first event was ${startedEvent.event.case ?? 'unknown'}, expected started`, + Code.Internal, + ) + } + } catch (err) { + if (typeof iterator.return === 'function') { + try { + await iterator.return() + } catch { + // ignore β€” best-effort cleanup + } + } + throw err + } + const startedAt = + startedEvent.event.value.startedAt !== undefined ? timestampDate(startedEvent.event.value.startedAt) : new Date() + + // Merge sandbox-level env + request env (request wins on collisions) so + // SandboxCommandExecution.env on the returned instance matches what the server actually + // executes and what a later GetCommand snapshot reads back. The server + // performs the same merge before persisting the SandboxCommandExecution row. + const mergedEnv = {...this._env, ...opts.env} + const command = _commandInternals.fromStartedEvent({ + cmdId: startedEvent.event.value.cmdId, + sandboxId: this.sandboxId, + cmd: opts.cmd, + args: opts.args ?? [], + cwd: opts.cwd, + env: mergedEnv, + sudo: opts.sudo ?? false, + detached: opts.detached ?? false, + startedAt, + }) + + // Replay the Started event into the buffer so `logs()` consumers + // attached before the next event arrives see the same Started β†’ ... β†’ + // Finished sequence a fresh AttachCommand would receive. + _commandInternals.ingest(command, startedEvent) + + if (opts.detached) { + // Fire-and-forget: the server ends the stream right after Started and + // keeps the command running in the sandbox. There is no further output to + // pump, so close the iterator instead of draining it β€” draining would see + // the stream end without a Finished event and wrongly mark the command + // failed. logs(), output(), and wait() throw for detached commands. + if (typeof iterator.return === 'function') { + try { + await iterator.return() + } catch { + // ignore β€” best-effort cleanup + } + } + return command + } + + // Background pump: drain the stream into the buffer. We don't await the + // pump directly β€” callers observe Finished via `command.wait()`, which + // subscribes to the buffer. Errors on the stream propagate via + // events.error(). + // drainStream owns its own try/catch and routes errors to + // _commandInternals.fail(), so the returned promise never rejects; + // a `.catch()` here would be dead code. We still need to consume the + // promise so an uncaught-rejection lint doesn't fire if drainStream + // is ever changed to surface errors β€” void-mark it explicitly. + void drainStream(command, iterator) + + return command + } + + /** + * The sandbox's file system, as a {@link FileSystem} bound to this sandbox. + * Its methods mirror `node:fs/promises`: + * + * ```ts + * const fs = sandbox.fs() + * await fs.mkdir('/work', {recursive: true}) + * await fs.writeFile('/work/hello.txt', 'hi') + * const stat = await fs.stat('/work/hello.txt') + * ``` + * + * Each call returns a fresh, lightweight {@link FileSystem} bound to the same + * sandbox and client. + */ + fs(): FileSystem { + return new FileSystem({client: this.client, sandboxId: this.sandboxId}) + } + + /** @internal Refresh this instance's fields from a proto returned by the server. */ + protected applyProto(sandbox: SandboxProto): void { + this._status = sandboxStatusFromProto(sandbox.status) + this._resources = resourcesFromProto(sandbox) + this._runtime = runtimeFromProto(sandbox) + this._createdAt = timestampToDate(sandbox.createdAt) + this._startedAt = timestampToDate(sandbox.startedAt) + this._stoppedAt = timestampToDate(sandbox.stoppedAt) + this._expiresAt = timestampToDate(sandbox.expiresAt) + this._timeoutMsRemaining = bigintToNumber(sandbox.timeoutMsRemaining) + this._activeCpuUsageMs = bigintToNumber(sandbox.activeCpuUsageMs) + this._networkUsage = networkUsageFromProto(sandbox.networkUsage) + this._exitCode = sandbox.exitCode + this._errorMessage = sandbox.errorMessage + this._name = sandbox.name + this._env = {...sandbox.env} + } + + /** + * The environment variables the server merges into every command run on + * this sandbox. Empty if the sandbox was created without any, or if it was + * fetched from a server that doesn't report them. + */ + get env(): Readonly> { + return this._env + } +} + +/** Options for {@link Sandbox.stop}. */ +export interface StopSandboxOpts { + /** + * Wait for the sandbox to finish cleaning up before resolving. By default + * the call resolves as soon as the server records the stop request, and you + * can poll {@link Sandbox.get} to watch for the status to change. + */ + blocking?: boolean +} + +/** Options for {@link Sandbox.kill}. */ +export interface KillSandboxOpts { + /** + * Signal name, such as `"SIGKILL"`. Accepted but currently ignored: every + * kill is a hard cancel regardless of the signal you pass. + */ + signal?: string +} + +/** Options for {@link Sandbox.create}. */ +export interface CreateSandboxOpts { + /** An optional name for the sandbox, unique within your organization. */ + name?: string + /** The resources to request. The server fills in defaults for anything you leave unset. */ + resources?: Resources + /** The runtime to use. Set exactly one of `named` or `imageRef`. */ + runtime?: Runtime + /** + * Environment variables for the sandbox. The server merges these into every + * command run on the sandbox. When a command sets the same variable, the + * command's value wins. + */ + env?: Record + /** + * Place the sandbox on a non-production compute pool. This is an unstable + * option for Depot-supported testing and may be removed without notice. + * Leave unset for normal placement. + */ + staging?: boolean +} + +/** Options for {@link Sandbox.list}. */ +export interface ListSandboxesOpts { + pagination?: Pagination + filter?: ListFilter +} + +/** Options for {@link Sandbox.listAll}. */ +export interface ListAllSandboxesOpts { + pageSize?: number + filter?: ListFilter +} + +/** Result of {@link Sandbox.list}. */ +export interface ListSandboxesResult { + sandboxes: Sandbox[] + pagination: PaginationResult +} + +function runtimeToProto(runtime: Runtime): RuntimeProto { + if ('named' in runtime) { + return create(RuntimeSchema, {runtime: {case: 'named', value: runtime.named}}) + } + return create(RuntimeSchema, {runtime: {case: 'imageRef', value: runtime.imageRef}}) +} + +function resourcesFromProto(sandbox: SandboxProto): Resources | undefined { + const r = sandbox.resources + if (!r) return undefined + return {vcpus: r.vcpus, memoryMb: r.memoryMb, diskGb: r.diskGb} +} + +function runtimeFromProto(sandbox: SandboxProto): Runtime | undefined { + const r = sandbox.runtime?.runtime + if (!r || r.case === undefined) return undefined + if (r.case === 'named') return {named: r.value} + return {imageRef: r.value} +} + +function timestampToDate(ts: Timestamp | undefined): Date | undefined { + return ts ? timestampDate(ts) : undefined +} + +// int64 proto fields arrive as bigint, but the SDK exposes them as plain +// numbers because that's easier to work with. The timeout and metering values +// we deal with stay well within Number.MAX_SAFE_INTEGER, so the conversion is +// safe in practice. Values above 2^53 would lose precision. +function bigintToNumber(value: bigint | undefined): number | undefined { + return value !== undefined ? Number(value) : undefined +} + +async function drainStream( + command: SandboxCommandExecution, + iterator: AsyncIterator, +): Promise { + let sawFinished = false + try { + while (true) { + const r = await iterator.next() + if (r.done) break + _commandInternals.ingest(command, r.value) + if (r.value.event.case === 'finished') { + sawFinished = true + break + } + } + if (sawFinished) { + _commandInternals.end(command) + } else { + // Server stream ended without a Finished event. The handler's + // no-exit-code path records the row as failed and then returns + // normally, so end()'ing here would leave the SDK object with + // status 'running' and output()/wait() resolving as if the + // command succeeded. Surface as a failure so callers see the + // discrepancy. + _commandInternals.fail( + command, + new Error(`SandboxCommandExecution ${command.cmdId} stream closed without Finished event`), + ) + } + } catch (err) { + _commandInternals.fail(command, err) + } finally { + // We obtained `iterator` by calling stream[Symbol.asyncIterator]() + // and manually drove it with .next(), so a regular for-await-of's + // implicit cleanup doesn't apply. If we broke out of the loop on + // 'finished' (or `_commandInternals.fail` raised), the underlying + // HTTP/2 stream stays open until GC reclaims the iterator. Signal + // explicitly so the transport can release it now. .return() is + // optional on AsyncIterator; guard for that and swallow errors so + // a misbehaving iterator doesn't shadow whatever we already + // recorded above. + if (typeof iterator.return === 'function') { + try { + await iterator.return() + } catch { + // ignore β€” best-effort cleanup + } + } + } +} + +function networkUsageFromProto(usage: NetworkUsageProto | undefined): NetworkUsage | undefined { + if (!usage) return undefined + return {ingressBytes: Number(usage.ingressBytes), egressBytes: Number(usage.egressBytes)} +} + +// The SDK's status strings are just the lowercased proto enum names, so we can +// convert by looking up the enum's name for a given numeric value. +function sandboxStatusFromProto(status: SandboxStatusProto): SandboxStatus | undefined { + if (status === SandboxStatusProto.UNSPECIFIED) return undefined + // A newer server can send a status this SDK build doesn't know about, which + // arrives as a raw number with no matching enum name. Return undefined for + // those rather than throwing, so an older SDK can still read every other + // field on the sandbox. + const name = SandboxStatusProto[status] + return name === undefined ? undefined : (name.toLowerCase() as SandboxStatus) +} + +function sandboxStatusToProto(status: SandboxStatus): SandboxStatusProto { + const value = SandboxStatusProto[status.toUpperCase() as keyof typeof SandboxStatusProto] + // The status union should always line up with the generated enum, so this + // can only happen if the two have drifted apart, or a caller forced an + // invalid value through a cast. Fail with a clear error instead of sending + // garbage to the server. + if (value === undefined) { + throw new Error(`unknown SandboxStatus: ${status}`) + } + return value +} + +function filterToProto(filter: ListFilter): ListSandboxesFilterProto { + return create(ListSandboxesRequest_FilterSchema, { + states: (filter.states ?? []).map(sandboxStatusToProto), + createdAfter: filter.createdAfter !== undefined ? timestampFromDate(filter.createdAfter) : undefined, + createdBefore: filter.createdBefore !== undefined ? timestampFromDate(filter.createdBefore) : undefined, + }) +} diff --git a/packages/sandbox/src/types.ts b/packages/sandbox/src/types.ts new file mode 100644 index 0000000..6c08086 --- /dev/null +++ b/packages/sandbox/src/types.ts @@ -0,0 +1,99 @@ +// Public types for the @depot/sandbox SDK. +// +// The generated proto types in ./gen/ stay close to the wire format: numeric +// enums, camelCase derived from snake_case, and proto Timestamp messages. The +// types here are the friendlier shapes we hand to SDK users instead: string +// unions for enums, Date in place of Timestamp, and discriminated unions in +// place of proto oneofs. + +/** + * The lifecycle status of a sandbox, as a string instead of the wire enum. + * + * The underlying proto enum includes an `UNSPECIFIED` zero value that the + * server never actually returns. Rather than expose it here, the conversion + * helper `sandboxStatusFromProto` maps that case to `undefined`. + */ +export type SandboxStatus = 'created' | 'assigned' | 'starting' | 'running' | 'finished' | 'cancelled' | 'failed' + +/** + * Bytes transferred over the sandbox's primary network interface. This is only + * reported once the sandbox reaches a terminal status, and only when a metering + * source is available server-side; it is `undefined` otherwise. + */ +export interface NetworkUsage { + ingressBytes: number + egressBytes: number +} + +/** + * The compute resources to request for a sandbox. Every field is optional; + * leaving one unset lets the server choose a default. + */ +export interface Resources { + vcpus?: number + memoryMb?: number + diskGb?: number +} + +/** + * Selects which runtime a sandbox boots into. Today the only supported option + * is `imageRef`, an arbitrary OCI image reference. + * + * The `named` variant is reserved for a future catalog of curated runtimes + * (for example `"node24"`) that the server would resolve by name. It is not + * usable yet: the server currently rejects any `named` value, so do not set it + * until the catalog ships. + */ +export type Runtime = {readonly named: string} | {readonly imageRef: string} + +/** Narrows the results of {@link Sandbox.list} and {@link Sandbox.listAll}. */ +export interface ListFilter { + /** When set, only return sandboxes whose status is one of these values. */ + states?: readonly SandboxStatus[] + /** Only return sandboxes created after this time. The bound is exclusive. */ + createdAfter?: Date + /** Only return sandboxes created before this time. The bound is exclusive. */ + createdBefore?: Date +} + +/** Controls how a list request is paginated. */ +export interface Pagination { + /** How many results to return per page. The server caps this, and picks a default when it is unset. */ + pageSize?: number + /** An opaque cursor from a previous call's `nextPageToken`, used to fetch the next page. */ + pageToken?: string +} + +/** The pagination details returned alongside a page of results. `nextPageToken` is undefined on the last page. */ +export interface PaginationResult { + nextPageToken?: string +} + +/** + * Friendly SDK alias of the generated `SandboxCommandExecutionStatus` proto enum. + * + * The proto enum carries a sentinel `UNSPECIFIED` zero value that the server + * never returns; that case is mapped to `undefined` at the SDK boundary + * rather than leaking into the union. + */ +export type SandboxCommandExecutionStatus = 'pending' | 'running' | 'finished' | 'failed' | 'killed' + +/** + * Options bag for {@link Sandbox.runCommand}. + * + * `detached` runs the command fire-and-forget (beta). With it set, + * {@link Sandbox.runCommand} returns as soon as the command starts, and the + * command keeps running in the sandbox afterward β€” but its output is not + * retained and can't be retrieved yet, so `logs()`, `output()`, and `wait()` + * on the returned execution throw, and the SDK logs a warning. Reattaching to a + * detached command's output is a future API. Leave `detached` unset + * for the normal blocking shape, where you stream output and await the result. + */ +export interface RunCommandOpts { + cmd: string + args?: string[] + cwd?: string + env?: Record + sudo?: boolean + detached?: boolean +} diff --git a/packages/sandbox/tsconfig.build.json b/packages/sandbox/tsconfig.build.json new file mode 100644 index 0000000..af8cdbb --- /dev/null +++ b/packages/sandbox/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json new file mode 100644 index 0000000..ac169ec --- /dev/null +++ b/packages/sandbox/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "module": "nodenext", + "target": "esnext", + "lib": ["esnext"], + "types": ["node"], + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "moduleDetection": "force", + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bef4c4..1b3c045 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,29 @@ importers: version: 0.18.1(prettier@3.3.3) tsup: specifier: ^8.2.4 - version: 8.2.4(typescript@5.5.4) + version: 8.2.4(tsx@4.22.4)(typescript@5.5.4) + typescript: + specifier: ^5.5.4 + version: 5.5.4 + + packages/sandbox: + dependencies: + '@connectrpc/connect': + specifier: ^2.1.0 + version: 2.1.0(@bufbuild/protobuf@2.8.0) + '@connectrpc/connect-node': + specifier: ^2.1.0 + version: 2.1.0(@bufbuild/protobuf@2.8.0)(@connectrpc/connect@2.1.0(@bufbuild/protobuf@2.8.0)) + devDependencies: + '@bufbuild/protobuf': + specifier: ^2.8.0 + version: 2.8.0 + '@types/node': + specifier: ^22.5.4 + version: 22.5.4 + tsx: + specifier: ^4.20.0 + version: 4.22.4 typescript: specifier: ^5.5.4 version: 5.5.4 @@ -91,6 +113,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.23.1': resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} engines: {node: '>=18'} @@ -103,6 +131,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.23.1': resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} engines: {node: '>=18'} @@ -115,6 +149,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.23.1': resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} engines: {node: '>=18'} @@ -127,6 +167,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.23.1': resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} engines: {node: '>=18'} @@ -139,6 +185,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.23.1': resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} engines: {node: '>=18'} @@ -151,6 +203,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.23.1': resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} engines: {node: '>=18'} @@ -163,6 +221,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.23.1': resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} engines: {node: '>=18'} @@ -175,6 +239,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.23.1': resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} engines: {node: '>=18'} @@ -187,6 +257,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.23.1': resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} engines: {node: '>=18'} @@ -199,6 +275,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.23.1': resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} engines: {node: '>=18'} @@ -211,6 +293,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.23.1': resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} engines: {node: '>=18'} @@ -223,6 +311,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.23.1': resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} engines: {node: '>=18'} @@ -235,6 +329,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.23.1': resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} engines: {node: '>=18'} @@ -247,6 +347,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.23.1': resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} engines: {node: '>=18'} @@ -259,6 +365,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.23.1': resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} engines: {node: '>=18'} @@ -271,6 +383,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.23.1': resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} engines: {node: '>=18'} @@ -283,12 +401,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.0': resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.23.1': resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} engines: {node: '>=18'} @@ -301,6 +431,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.23.1': resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} engines: {node: '>=18'} @@ -313,6 +449,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.23.1': resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} engines: {node: '>=18'} @@ -325,6 +467,18 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.23.1': resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} engines: {node: '>=18'} @@ -337,6 +491,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.23.1': resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} engines: {node: '>=18'} @@ -349,6 +509,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.23.1': resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} engines: {node: '>=18'} @@ -361,6 +527,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.23.1': resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} engines: {node: '>=18'} @@ -373,6 +545,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -616,6 +794,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -943,6 +1126,11 @@ packages: typescript: optional: true + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.4.5: resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} engines: {node: '>=14.17'} @@ -1010,147 +1198,225 @@ snapshots: '@esbuild/aix-ppc64@0.25.0': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.23.1': optional: true '@esbuild/android-arm64@0.25.0': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.23.1': optional: true '@esbuild/android-arm@0.25.0': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.23.1': optional: true '@esbuild/android-x64@0.25.0': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.23.1': optional: true '@esbuild/darwin-arm64@0.25.0': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.23.1': optional: true '@esbuild/darwin-x64@0.25.0': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.23.1': optional: true '@esbuild/freebsd-arm64@0.25.0': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.23.1': optional: true '@esbuild/freebsd-x64@0.25.0': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.23.1': optional: true '@esbuild/linux-arm64@0.25.0': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.23.1': optional: true '@esbuild/linux-arm@0.25.0': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.23.1': optional: true '@esbuild/linux-ia32@0.25.0': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.23.1': optional: true '@esbuild/linux-loong64@0.25.0': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.23.1': optional: true '@esbuild/linux-mips64el@0.25.0': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.23.1': optional: true '@esbuild/linux-ppc64@0.25.0': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.23.1': optional: true '@esbuild/linux-riscv64@0.25.0': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.23.1': optional: true '@esbuild/linux-s390x@0.25.0': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.23.1': optional: true '@esbuild/linux-x64@0.25.0': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.0': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.23.1': optional: true '@esbuild/netbsd-x64@0.25.0': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.23.1': optional: true '@esbuild/openbsd-arm64@0.25.0': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.23.1': optional: true '@esbuild/openbsd-x64@0.25.0': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.23.1': optional: true '@esbuild/sunos-x64@0.25.0': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.23.1': optional: true '@esbuild/win32-arm64@0.25.0': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.23.1': optional: true '@esbuild/win32-ia32@0.25.0': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.23.1': optional: true '@esbuild/win32-x64@0.25.0': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1397,6 +1663,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.0 '@esbuild/win32-x64': 0.25.0 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -1551,9 +1846,11 @@ snapshots: pirates@4.0.6: {} - postcss-load-config@6.0.1: + postcss-load-config@6.0.1(tsx@4.22.4): dependencies: lilconfig: 3.1.2 + optionalDependencies: + tsx: 4.22.4 prettier-plugin-organize-imports@4.0.0(prettier@3.3.3)(typescript@5.5.4): dependencies: @@ -1672,7 +1969,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.2.4(typescript@5.5.4): + tsup@8.2.4(tsx@4.22.4)(typescript@5.5.4): dependencies: bundle-require: 5.0.0(esbuild@0.23.1) cac: 6.7.14 @@ -1684,7 +1981,7 @@ snapshots: globby: 11.1.0 joycon: 3.1.1 picocolors: 1.1.0 - postcss-load-config: 6.0.1 + postcss-load-config: 6.0.1(tsx@4.22.4) resolve-from: 5.0.0 rollup: 4.24.0 source-map: 0.8.0-beta.0 @@ -1698,6 +1995,12 @@ snapshots: - tsx - yaml + tsx@4.22.4: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.4.5: {} typescript@5.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d9368ad --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - . + - packages/*