Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,36 @@ stub's `exec` rejects a raw tagged-template call so the unescaped path
fails loudly. `shellQuote` is exported too, for the rare case where
you need to quote a single argument outside a template.

### Reattaching to a run

A command outlives the request that started it. Every handle carries
the run's `id`, and `shell.get(id)` reattaches to it from a later
request:

```ts
using ws = await getWorkspace(env.ContainerExample.get(id));

// First request: start a long install and remember its id.
using started = await ws.shell.exec("npm install", { id: "install-1" });

// Later request, new handle, same run. `resume` picks where the
// replayed event stream starts: "tail" for live events only, "full"
// (the default) for everything the runner still holds, or a sequence
// number to resume after.
using again = await ws.shell.get("install-1", { encoding: "utf8", resume: "tail" });
const { exitCode } = await again.result();
```

The client mints an id when the caller doesn't pass one, so
`handle.id` is available either way. Reattach doesn't run the
push/pull bracket — it joins a run already in flight — so its sync
counts cover only what lands after the reattach.

A run streams to one reader at a time. Dropping a handle — the `using`
above, or an explicit `cancel()` — hands the stream back, which is what
lets the next request reattach. Reattaching while an earlier handle is
still reading is refused.

## Observability

The package emits one span per documented operation through an optional
Expand Down
88 changes: 74 additions & 14 deletions packages/computer/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,25 @@ interface ExecCall {
options: Record<string, unknown> | undefined;
}

// A fake shell that records exec calls. Stands in for both
interface GetCall {
id: string;
options: Record<string, unknown> | undefined;
}

// A fake shell that records exec and get calls. Stands in for both
// Workspace.shell (local) and the shell stub (remote) — the client
// only needs `exec(command, options?)`.
// only needs `exec(command, options?)` and `get(id, options?)`.
function fakeShell(): {
shell: { exec: (c: string, o?: Record<string, unknown>) => Promise<unknown> };
shell: {
exec: (c: string, o?: Record<string, unknown>) => Promise<unknown>;
get: (id: string, o?: Record<string, unknown>) => Promise<unknown>;
};
calls: ExecCall[];
gets: GetCall[];
disposedHandles: () => number;
} {
const calls: ExecCall[] = [];
const gets: GetCall[] = [];
let disposedHandles = 0;
// A minimal handle satisfying both the local identity rehydrate
// (which passes it through) and the remote rebuild (which calls
Expand All @@ -53,12 +63,17 @@ function fakeShell(): {
});
return {
calls,
gets,
disposedHandles: () => disposedHandles,
shell: {
exec(command: string, options?: Record<string, unknown>) {
calls.push({ command, options });
return Promise.resolve(makeHandle());
},
get(id: string, options?: Record<string, unknown>) {
gets.push({ id, options });
return Promise.resolve(makeHandle());
},
},
};
}
Expand All @@ -68,10 +83,11 @@ function fakeShell(): {
function fakeRemote(): {
host: WorkspaceStubHost;
calls: ExecCall[];
gets: GetCall[];
disposed: () => boolean;
disposedHandles: () => number;
} {
const { shell, calls, disposedHandles } = fakeShell();
const { shell, calls, gets, disposedHandles } = fakeShell();
let disposed = false;
const stub = {
fs: { marker: "fs" },
Expand All @@ -86,6 +102,7 @@ function fakeRemote(): {
};
return {
calls,
gets,
disposed: () => disposed,
disposedHandles,
host: { __getWorkspaceStub: () => Promise.resolve(stub as never) },
Expand Down Expand Up @@ -114,11 +131,15 @@ function fakeBrokenRemote(): {
// Local path fake: an object carrying a real Workspace under the
// stash symbol, but with its shell swapped for a recording fake so we
// can assert on exec without a backend.
function fakeLocal(): { host: { [WORKSPACE]: Workspace }; calls: ExecCall[] } {
function fakeLocal(): {
host: { [WORKSPACE]: Workspace };
calls: ExecCall[];
gets: GetCall[];
} {
const ws = new Workspace({ storage: new SQLiteTestStorage() });
const { shell, calls } = fakeShell();
const { shell, calls, gets } = fakeShell();
Object.defineProperty(ws, "shell", { get: () => shell });
return { calls, host: { [WORKSPACE]: ws } };
return { calls, gets, host: { [WORKSPACE]: ws } };
}

describe("getWorkspace — remote dispatch", () => {
Expand Down Expand Up @@ -205,7 +226,7 @@ describe("client shell.exec — tagged template form", () => {
const { host, calls } = fakeRemote();
const ws = await getWorkspace(host);
await ws.shell.exec`ls`;
expect(calls[0].options).toEqual({ encoding: "utf8" });
expect(calls[0].options).toMatchObject({ encoding: "utf8" });
});

it("quotes each element of an interpolated array", async () => {
Expand All @@ -217,21 +238,19 @@ describe("client shell.exec — tagged template form", () => {
});

describe("client shell.exec — plain string form", () => {
it("forwards a bare command with no options", async () => {
it("forwards a bare command", async () => {
const { host, calls } = fakeRemote();
const ws = await getWorkspace(host);
await ws.shell.exec("npm test");
expect(calls[0]).toEqual({ command: "npm test", options: undefined });
expect(calls[0].command).toBe("npm test");
});

it("forwards options unchanged", async () => {
const { host, calls } = fakeRemote();
const ws = await getWorkspace(host);
await ws.shell.exec("npm test", { cwd: "/workspace", backend: "sandbox" });
expect(calls[0]).toEqual({
command: "npm test",
options: { cwd: "/workspace", backend: "sandbox" },
});
expect(calls[0].command).toBe("npm test");
expect(calls[0].options).toMatchObject({ cwd: "/workspace", backend: "sandbox" });
});

it("does not escape a plain string command", async () => {
Expand Down Expand Up @@ -274,6 +293,25 @@ describe("client shell.exec — remote handle rebuild", () => {
expect(() => (handle as { result(): unknown }).result()).toThrow(/already streaming/);
});

it("carries the id the caller asked for onto the rebuilt handle", async () => {
const { host, calls } = fakeRemote();
const ws = await getWorkspace(host);
const handle = await ws.shell.exec("npm install", { id: "install-1" });
expect(handle.id).toBe("install-1");
expect(calls[0].options).toMatchObject({ id: "install-1" });
});

it("mints an id when the caller omits one so the handle can be reattached", async () => {
const { host, calls } = fakeRemote();
const ws = await getWorkspace(host);
const handle = await ws.shell.exec("npm install");
// The minted id is what went over the wire, so a later
// shell.get(handle.id) addresses this run.
expect(typeof handle.id).toBe("string");
expect(handle.id).not.toBe("");
expect(calls[0].options?.id).toBe(handle.id);
});

it("disposes the remote handle when the rebuilt handle is disposed", async () => {
const { host, disposedHandles } = fakeRemote();
const ws = await getWorkspace(host);
Expand All @@ -282,3 +320,25 @@ describe("client shell.exec — remote handle rebuild", () => {
expect(disposedHandles()).toBe(1);
});
});

describe("client shell.get — reattach", () => {
it("reattaches over RPC and rebuilds a handle carrying the run's id", async () => {
const { host, gets } = fakeRemote();
const ws = await getWorkspace(host);
const handle = await ws.shell.get("install-1", { encoding: "utf8", resume: "tail" });
expect(gets[0]).toEqual({
id: "install-1",
options: { encoding: "utf8", resume: "tail" },
});
expect(handle.id).toBe("install-1");
const result = await handle.result();
expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" });
});

it("delegates to the in-isolate Workspace on the local path", async () => {
const { host, gets } = fakeLocal();
const ws = await getWorkspace(host);
await ws.shell.get("install-1", { resume: 12 });
expect(gets[0]).toEqual({ id: "install-1", options: { resume: 12 } });
});
});
76 changes: 61 additions & 15 deletions packages/computer/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface RemoteExecHandle {
}

// Rebuild a host-shaped ExecHandle from a remote handle stub. The
// result is a ReadableStream of decoded events with result() and
// result is a ReadableStream of decoded events with id, result() and
// kill() tacked on, matching what the local path returns from
// Workspace.shell.exec.
//
Expand All @@ -61,7 +61,7 @@ interface RemoteExecHandle {
// whichever is used first wins. result() goes through the stub's
// run-and-wait path (which runs the post-exit pull); iterating goes
// through the stub's byte stream (which doesn't).
function rebuildExecHandle<E extends ExecEncoding>(remote: RemoteExecHandle): unknown {
function rebuildExecHandle<E extends ExecEncoding>(remote: RemoteExecHandle, id: string): unknown {
let started = false;
let reader: ReadableStreamDefaultReader<WorkspaceExecEvent<E>> | undefined;
const stream = new ReadableStream<WorkspaceExecEvent<E>>(
Expand Down Expand Up @@ -101,11 +101,16 @@ function rebuildExecHandle<E extends ExecEncoding>(remote: RemoteExecHandle): un
remote[Symbol.dispose]?.();
};
const handle = stream as ReadableStream<WorkspaceExecEvent<E>> & {
readonly id: string;
result(): Promise<unknown>;
kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise<void>;
[Symbol.dispose](): void;
};
Object.defineProperties(handle, {
id: {
value: id,
enumerable: false,
},
result: {
value: () => {
if (started) {
Expand Down Expand Up @@ -140,13 +145,25 @@ function rebuildExecHandle<E extends ExecEncoding>(remote: RemoteExecHandle): un
// the underlying surface's default. Wrap an interpolated command
// in `sh` to escape it: `exec(sh`cat ${file}`, { cwd })`.
//
// `get` reattaches to a run by id, from any request and any handle:
//
// using ws = await getWorkspace(env.MyDO.get(id));
// const handle = await ws.shell.get(runId, { resume: "tail" });
//
// The id comes off a prior handle's `id` or from the caller's own
// `exec` options, so a long command outlives the request that started
// it.
//
// `R` is the handle type the underlying surface returns (the host
// `ExecHandle` locally, the handle stub remotely).
export interface WorkspaceShellClient<RUtf8, ROpts, RBytes> {
exec(strings: TemplateStringsArray, ...values: ShellValue[]): Promise<RUtf8>;
exec(command: string): Promise<RBytes>;
exec(command: string, options: ShellExecOptions & { encoding: "utf8" }): Promise<RUtf8>;
exec(command: string, options: ShellExecOptions): Promise<ROpts>;
get(id: string): Promise<RBytes>;
get(id: string, options: ShellGetOptions & { encoding: "utf8" }): Promise<RUtf8>;
get(id: string, options: ShellGetOptions): Promise<ROpts>;
}

// Options accepted by the plain `exec` form, common to both paths.
Expand All @@ -158,26 +175,39 @@ export interface ShellExecOptions {
timeoutMs?: number;
}

// Options accepted by `get`, common to both paths. `resume` picks
// where the replayed event stream starts: "tail" for live events only,
// "full" (the default) for everything buffered, or a sequence number
// to resume after.
export interface ShellGetOptions {
encoding?: "utf8";
resume?: "tail" | "full" | number;
backend?: string;
}

function isTemplateStringsArray(value: unknown): value is TemplateStringsArray {
// A tagged-template call hands the cooked strings as the first
// argument: an array. A plain `exec(command)` call hands a string.
return Array.isArray(value);
}

// The underlying shell surface both paths expose: an `exec` taking a
// command string and options. Locally this is `Workspace.shell`;
// remotely it's the shell stub.
// command string and options, and a `get` taking an exec id. Locally
// this is `Workspace.shell`; remotely it's the shell stub.
interface UnderlyingShell {
// biome-ignore lint/suspicious/noExplicitAny: bridges two concrete exec overload sets
exec(command: string, options?: Record<string, unknown>): Promise<any>;
// biome-ignore lint/suspicious/noExplicitAny: bridges two concrete get overload sets
get(id: string, options?: Record<string, unknown>): Promise<any>;
}

function makeShellClient(
shell: UnderlyingShell,
// Adapts the handle the underlying `exec` resolves to: identity on
// the local path (already a host ExecHandle), rebuild on the remote
// path (a handle stub that needs inflating from its JSONL stream).
rehydrate: (handle: unknown) => unknown,
// the local path (already a host ExecHandle carrying its own id),
// rebuild on the remote path (a handle stub that needs inflating
// from its JSONL stream and carries no id of its own).
rehydrate: (handle: unknown, id: string) => unknown,
// biome-ignore lint/suspicious/noExplicitAny: handle types differ per path
): WorkspaceShellClient<any, any, any> {
async function exec(
Expand All @@ -189,17 +219,33 @@ function makeShellClient(
if (isTemplateStringsArray(commandOrStrings)) {
const values = optionsOrValue === undefined ? rest : [optionsOrValue as ShellValue, ...rest];
const command = sh(commandOrStrings, ...values);
return rehydrate(await shell.exec(command, { encoding: "utf8" }));
const id = crypto.randomUUID();
return rehydrate(await shell.exec(command, { id, encoding: "utf8" }), id);
}
const options = optionsOrValue as ShellExecOptions | undefined;
const options = (optionsOrValue as ShellExecOptions | undefined) ?? {};
// Every run is addressed by an id the caller knows, so the handle
// exposes one on both paths and a later get() can reattach to a
// command that outlived the request that started it. A caller
// passing its own id keeps it; minting one here rather than
// reading the runner's back saves a round trip on the remote
// path, and the shell stub rejects a spawn that came back under a
// different id.
const id = options.id ?? crypto.randomUUID();
return rehydrate(await shell.exec(commandOrStrings, { ...options, id }), id);
}
async function get(
id: string,
options?: ShellGetOptions,
// biome-ignore lint/suspicious/noExplicitAny: handle types differ per path
): Promise<any> {
const handle =
options === undefined
? await shell.exec(commandOrStrings)
: await shell.exec(commandOrStrings, options as Record<string, unknown>);
return rehydrate(handle);
? await shell.get(id)
: await shell.get(id, options as Record<string, unknown>);
return rehydrate(handle, id);
}
// biome-ignore lint/suspicious/noExplicitAny: handle types differ per path
return { exec } as WorkspaceShellClient<any, any, any>;
return { exec, get } as WorkspaceShellClient<any, any, any>;
}

// The canonical client surface. `shell.exec` is the adapted member;
Expand All @@ -222,7 +268,7 @@ export interface WorkspaceClient extends Partial<ThinkWorkspaceCompatibility> {
function makeClient(
// biome-ignore lint/suspicious/noExplicitAny: underlying surface differs per path
surface: any,
rehydrate: (handle: unknown) => unknown,
rehydrate: (handle: unknown, id: string) => unknown,
dispose: () => void,
useThink: boolean,
): WorkspaceClient {
Expand Down Expand Up @@ -280,7 +326,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise<WorkspaceCl
try {
return makeClient(
stub,
(h) => rebuildExecHandle(h as RemoteExecHandle),
(h, id) => rebuildExecHandle(h as RemoteExecHandle, id),
() => {
(stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
},
Expand Down
2 changes: 2 additions & 0 deletions packages/computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { TestBackend, type TestBackendOptions } from "./backends/test.js";
export {
getWorkspace,
type ShellExecOptions,
type ShellGetOptions,
type WorkspaceClient,
type WorkspaceHandle,
type WorkspaceShellClient,
Expand Down Expand Up @@ -73,6 +74,7 @@ export {
type WorkspaceExecOptions,
type WorkspaceExecResult,
WorkspaceFilesystemStub,
type WorkspaceGetExecOptions,
WorkspaceGitStub,
WorkspaceShellStub,
WorkspaceStub,
Expand Down
Loading
Loading