diff --git a/.claude/commands/post-release-testing.md b/.claude/commands/post-release-testing.md index 09e2b6a..10cf6ff 100644 --- a/.claude/commands/post-release-testing.md +++ b/.claude/commands/post-release-testing.md @@ -43,7 +43,7 @@ Manually verify the install script works in a fresh environment: ```bash docker run --rm alpine:latest sh -c " apk add --no-cache curl ca-certificates libstdc++ libgcc bash && - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh && sandbox-agent --version " ``` diff --git a/.gitignore b/.gitignore index 7b6c859..de4d863 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ sdks/cli/platforms/*/bin/ # Foundry desktop app build artifacts foundry/packages/desktop/frontend-dist/ foundry/packages/desktop/src-tauri/sidecars/ +.context/ diff --git a/CLAUDE.md b/CLAUDE.md index 4935aa5..248f075 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,20 +20,7 @@ - For HTTP/CLI docs/examples, source of truth is: - `server/packages/sandbox-agent/src/router.rs` - `server/packages/sandbox-agent/src/cli.rs` -- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy `/v1/sessions` APIs). - -## E2E Agent Testing - -- When asked to test agents e2e and you do not have the API tokens/credentials required, always stop and ask the user where to find the tokens before proceeding. - -## ACP Adapter Audit - -- `scripts/audit-acp-deps/adapters.json` is the single source of truth for ACP adapter npm packages, pinned versions, and the `@agentclientprotocol/sdk` pin. -- The Rust fallback install path in `server/packages/agent-management/src/agents.rs` reads adapter entries from `adapters.json` at compile time via `include_str!`. -- Run `cd scripts/audit-acp-deps && npx tsx audit.ts` to compare our pinned versions against the ACP registry and npm latest. -- When bumping an adapter version, update `adapters.json` only — the Rust code picks it up automatically. -- When adding a new agent, add an entry to `adapters.json` (the `_` fallback arm in `install_agent_process_fallback` handles it). -- When updating the `@agentclientprotocol/sdk` pin, update both `adapters.json` (sdkDeps) and `sdks/acp-http-client/package.json`. +- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy session REST APIs). ## Change Tracking @@ -43,41 +30,22 @@ - Regenerate `docs/openapi.json` when HTTP contracts change. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Append blockers/decisions to `research/acp/friction.md` during ACP work. -- Each agent has its own doc page at `docs/agents/.mdx` listing models, modes, and thought levels. Update the relevant page when changing `fallback_config_options`. To regenerate capability data, run `cd scripts/agent-configs && npx tsx dump.ts`. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`). +- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`). - Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier. -## Adding Providers +## Docker Test Image -When adding a new sandbox provider, update all of the following: +- Docker-backed Rust and TypeScript tests build `docker/test-agent/Dockerfile` directly in-process and cache the image tag only in memory (`OnceLock` in Rust, module-level variable in TypeScript). +- Do not add cross-process image-build scripts unless there is a concrete need for them. -- `sdks/typescript/src/providers/.ts` — provider implementation -- `sdks/typescript/package.json` — add `./` export, peerDependencies, peerDependenciesMeta, devDependencies -- `sdks/typescript/tsup.config.ts` — add entry point and external -- `sdks/typescript/tests/providers.test.ts` — add test entry -- `examples//` — create example with `src/index.ts` and `tests/.test.ts` -- `docs/deploy/.mdx` — create deploy guide -- `docs/docs.json` — add to Deploy pages navigation -- `docs/quickstart.mdx` — add tab in "Start the sandbox" step, add credentials entry in "Passing LLM credentials" accordion +## Common Software Sync -## Adding Agents - -When adding a new agent, update all of the following: - -- `docs/agents/.mdx` — create agent page with usage snippet and capabilities table -- `docs/docs.json` — add to the Agents group under Agent -- `docs/quickstart.mdx` — add tab in the "Create a session and send a prompt" CodeGroup - -## Persist Packages (Deprecated) - -- The `@sandbox-agent/persist-*` npm packages (`persist-sqlite`, `persist-postgres`, `persist-indexeddb`, `persist-rivet`) are deprecated stubs. They still publish to npm but throw a deprecation error at import time. -- Driver implementations now live inline in examples and consuming packages: - - SQLite: `examples/persist-sqlite/src/persist.ts` - - Postgres: `examples/persist-postgres/src/persist.ts` - - IndexedDB: `frontend/packages/inspector/src/persist-indexeddb.ts` - - Rivet: inlined in `docs/multiplayer.mdx` - - In-memory: built into the main `sandbox-agent` SDK (`InMemorySessionPersistDriver`) -- Docs (`docs/session-persistence.mdx`) link to the example implementations on GitHub instead of referencing the packages. -- Do not re-add `@sandbox-agent/persist-*` as dependencies anywhere. New persist drivers should be copied into the consuming project directly. +- These three files must stay in sync: + - `docs/common-software.mdx` (user-facing documentation) + - `docker/test-common-software/Dockerfile` (packages installed in the test image) + - `server/packages/sandbox-agent/tests/common_software.rs` (test assertions) +- When adding or removing software from `docs/common-software.mdx`, also add/remove the corresponding `apt-get install` line in the Dockerfile and add/remove the test in `common_software.rs`. +- Run `cargo test -p sandbox-agent --test common_software` to verify. ## Install Version References @@ -93,28 +61,20 @@ When adding a new agent, update all of the following: - `docs/sdk-overview.mdx` - `docs/react-components.mdx` - `docs/session-persistence.mdx` - - `docs/architecture.mdx` - `docs/deploy/local.mdx` - `docs/deploy/cloudflare.mdx` - `docs/deploy/vercel.mdx` - `docs/deploy/daytona.mdx` - `docs/deploy/e2b.mdx` - `docs/deploy/docker.mdx` - - `docs/deploy/boxlite.mdx` - - `docs/deploy/modal.mdx` - - `docs/deploy/computesdk.mdx` - `frontend/packages/website/src/components/GetStarted.tsx` - `.claude/commands/post-release-testing.md` - `examples/cloudflare/Dockerfile` - - `examples/boxlite/Dockerfile` - - `examples/boxlite-python/Dockerfile` - `examples/daytona/src/index.ts` - `examples/shared/src/docker.ts` - `examples/docker/src/index.ts` - `examples/e2b/src/index.ts` - `examples/vercel/src/index.ts` - - `sdks/typescript/src/providers/shared.ts` - `scripts/release/main.ts` - `scripts/release/promote-artifacts.ts` - `scripts/release/sdk.ts` - - `scripts/sandbox-testing/test-sandbox.ts` diff --git a/Cargo.toml b/Cargo.toml index 163ab69..0fc4dc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["server/packages/*", "gigacode"] exclude = ["factory/packages/desktop/src-tauri", "foundry/packages/desktop/src-tauri"] [workspace.package] -version = "0.4.1-rc.1" +version = "0.4.2" edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" @@ -13,13 +13,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports [workspace.dependencies] # Internal crates -sandbox-agent = { version = "0.4.1-rc.1", path = "server/packages/sandbox-agent" } -sandbox-agent-error = { version = "0.4.1-rc.1", path = "server/packages/error" } -sandbox-agent-agent-management = { version = "0.4.1-rc.1", path = "server/packages/agent-management" } -sandbox-agent-agent-credentials = { version = "0.4.1-rc.1", path = "server/packages/agent-credentials" } -sandbox-agent-opencode-adapter = { version = "0.4.1-rc.1", path = "server/packages/opencode-adapter" } -sandbox-agent-opencode-server-manager = { version = "0.4.1-rc.1", path = "server/packages/opencode-server-manager" } -acp-http-adapter = { version = "0.4.1-rc.1", path = "server/packages/acp-http-adapter" } +sandbox-agent = { version = "0.4.2", path = "server/packages/sandbox-agent" } +sandbox-agent-error = { version = "0.4.2", path = "server/packages/error" } +sandbox-agent-agent-management = { version = "0.4.2", path = "server/packages/agent-management" } +sandbox-agent-agent-credentials = { version = "0.4.2", path = "server/packages/agent-credentials" } +sandbox-agent-opencode-adapter = { version = "0.4.2", path = "server/packages/opencode-adapter" } +sandbox-agent-opencode-server-manager = { version = "0.4.2", path = "server/packages/opencode-server-manager" } +acp-http-adapter = { version = "0.4.2", path = "server/packages/acp-http-adapter" } # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index eb427d7..cf9b933 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,11 @@ Import the SDK directly into your Node or browser application. Full type safety **Install** ```bash -npm install sandbox-agent@0.3.x +npm install sandbox-agent@0.4.x ``` ```bash -bun add sandbox-agent@0.3.x +bun add sandbox-agent@0.4.x # Optional: allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -135,7 +135,7 @@ Run as an HTTP server and connect from any language. Deploy to E2B, Daytona, Ver ```bash # Install it -curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh # Run it sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 ``` @@ -159,12 +159,12 @@ sandbox-agent server --no-token --host 127.0.0.1 --port 2468 Install the CLI wrapper (optional but convenient): ```bash -npm install -g @sandbox-agent/cli@0.3.x +npm install -g @sandbox-agent/cli@0.4.x ``` ```bash # Allow Bun to run postinstall scripts for native binaries. -bun add -g @sandbox-agent/cli@0.3.x +bun add -g @sandbox-agent/cli@0.4.x bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -179,11 +179,11 @@ sandbox-agent api sessions send-message-stream my-session --message "Hello" --en You can also use npx like: ```bash -npx @sandbox-agent/cli@0.3.x --help +npx @sandbox-agent/cli@0.4.x --help ``` ```bash -bunx @sandbox-agent/cli@0.3.x --help +bunx @sandbox-agent/cli@0.4.x --help ``` [CLI documentation](https://sandboxagent.dev/docs/cli) diff --git a/docker/inspector-dev/Dockerfile b/docker/inspector-dev/Dockerfile new file mode 100644 index 0000000..b55923f --- /dev/null +++ b/docker/inspector-dev/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-bookworm-slim + +RUN npm install -g pnpm@10.28.2 + +WORKDIR /app + +CMD ["bash", "-lc", "pnpm install --filter @sandbox-agent/inspector... && cd frontend/packages/inspector && exec pnpm vite --host 0.0.0.0 --port 5173"] diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index e0a3335..85473be 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -149,7 +149,8 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y \ ca-certificates \ curl \ - git && \ + git \ + ffmpeg && \ rm -rf /var/lib/apt/lists/* # Copy the binary from builder diff --git a/docker/test-agent/Dockerfile b/docker/test-agent/Dockerfile new file mode 100644 index 0000000..67888b3 --- /dev/null +++ b/docker/test-agent/Dockerfile @@ -0,0 +1,61 @@ +FROM rust:1.88.0-bookworm AS builder +WORKDIR /build + +COPY Cargo.toml Cargo.lock ./ +COPY server/ ./server/ +COPY gigacode/ ./gigacode/ +COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/ +COPY scripts/agent-configs/ ./scripts/agent-configs/ +COPY scripts/audit-acp-deps/ ./scripts/audit-acp-deps/ + +ENV SANDBOX_AGENT_SKIP_INSPECTOR=1 + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release && \ + cp target/release/sandbox-agent /sandbox-agent + +# Extract neko binary from the official image for WebRTC desktop streaming. +# Using neko v3 base image from GHCR which provides multi-arch support (amd64, arm64). +# Pinned by digest to prevent breaking changes from upstream. +# Reference client: https://github.com/demodesk/neko-client/blob/37f93eae6bd55b333c94bd009d7f2b079075a026/src/component/internal/webrtc.ts +FROM ghcr.io/m1k1o/neko/base@sha256:0c384afa56268aaa2d5570211d284763d0840dcdd1a7d9a24be3081d94d3dfce AS neko-base + +FROM node:22-bookworm-slim +RUN apt-get update -qq && \ + apt-get install -y -qq --no-install-recommends \ + ca-certificates \ + bash \ + libstdc++6 \ + xvfb \ + openbox \ + xdotool \ + imagemagick \ + ffmpeg \ + gstreamer1.0-tools \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-nice \ + gstreamer1.0-x \ + gstreamer1.0-pulseaudio \ + libxcvt0 \ + x11-xserver-utils \ + dbus-x11 \ + xauth \ + fonts-dejavu-core \ + xterm \ + > /dev/null 2>&1 && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent +COPY --from=neko-base /usr/bin/neko /usr/local/bin/neko + +EXPOSE 3000 +# Expose UDP port range for WebRTC media transport +EXPOSE 59050-59070/udp + +ENTRYPOINT ["/usr/local/bin/sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"] diff --git a/docker/test-common-software/Dockerfile b/docker/test-common-software/Dockerfile new file mode 100644 index 0000000..7a03abc --- /dev/null +++ b/docker/test-common-software/Dockerfile @@ -0,0 +1,37 @@ +# Extends the base test-agent image with common software pre-installed. +# Used by the common_software integration test to verify that all documented +# software in docs/common-software.mdx works correctly inside the sandbox. +# +# KEEP IN SYNC with docs/common-software.mdx + +ARG BASE_IMAGE=sandbox-agent-test:dev +FROM ${BASE_IMAGE} + +USER root + +RUN apt-get update -qq && \ + apt-get install -y -qq --no-install-recommends \ + # Browsers + chromium \ + firefox-esr \ + # Languages + python3 python3-pip python3-venv \ + default-jdk \ + ruby-full \ + # Databases + sqlite3 \ + redis-server \ + # Build tools + build-essential cmake pkg-config \ + # CLI tools + git jq tmux \ + # Media and graphics + imagemagick \ + poppler-utils \ + # Desktop apps + gimp \ + > /dev/null 2>&1 && \ + rm -rf /var/lib/apt/lists/* + +ENTRYPOINT ["/usr/local/bin/sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"] diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx index 0f9e2ab..0154537 100644 --- a/docs/agent-sessions.mdx +++ b/docs/agent-sessions.mdx @@ -51,6 +51,108 @@ await session.prompt([ unsubscribe(); ``` +### Event types + +Each event's `payload` contains a session update. The `sessionUpdate` field identifies the type. + + + +Streamed text or content from the agent's response. + +```json +{ + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "Here's how the repository is structured..." } +} +``` + + + +Internal reasoning from the agent (chain-of-thought / extended thinking). + +```json +{ + "sessionUpdate": "agent_thought_chunk", + "content": { "type": "text", "text": "I should start by looking at the project structure..." } +} +``` + + + +Echo of the user's prompt being processed. + +```json +{ + "sessionUpdate": "user_message_chunk", + "content": { "type": "text", "text": "Summarize the repository structure." } +} +``` + + + +The agent invoked a tool (file edit, terminal command, etc.). + +```json +{ + "sessionUpdate": "tool_call", + "toolCallId": "tc_abc123", + "title": "Read file", + "status": "in_progress", + "rawInput": { "path": "/src/index.ts" } +} +``` + + + +Progress or result update for an in-progress tool call. + +```json +{ + "sessionUpdate": "tool_call_update", + "toolCallId": "tc_abc123", + "status": "completed", + "content": [{ "type": "text", "text": "import express from 'express';\n..." }] +} +``` + + + +The agent's execution plan for the current task. + +```json +{ + "sessionUpdate": "plan", + "entries": [ + { "content": "Read the project structure", "status": "completed" }, + { "content": "Identify main entrypoints", "status": "in_progress" }, + { "content": "Write summary", "status": "pending" } + ] +} +``` + + + +Token usage metrics for the current turn. + +```json +{ + "sessionUpdate": "usage_update" +} +``` + + + +Session metadata changed (e.g. agent-generated title). + +```json +{ + "sessionUpdate": "session_info_update", + "title": "Repository structure analysis" +} +``` + + + ## Fetch persisted event history ```ts diff --git a/docs/architecture.mdx b/docs/architecture.mdx index a623c94..61b4689 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -56,7 +56,7 @@ Agents are installed lazily on first use. To avoid the cold-start delay, pre-ins sandbox-agent install-agent --all ``` -The `rivetdev/sandbox-agent:0.4.1-rc.1-full` Docker image ships with all agents pre-installed. +The `rivetdev/sandbox-agent:0.4.2-full` Docker image ships with all agents pre-installed. ## Production-ready agent orchestration diff --git a/docs/cli.mdx b/docs/cli.mdx index 2ad3b08..362de49 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -37,6 +37,36 @@ Notes: - Set `SANDBOX_AGENT_LOG_STDOUT=1` to force stdout/stderr logging. - Use `SANDBOX_AGENT_LOG_DIR` to override log directory. +## install + +Install first-party runtime dependencies. + +### install desktop + +Install the Linux desktop runtime packages required by `/v1/desktop/*`. + +```bash +sandbox-agent install desktop [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--yes` | Skip the confirmation prompt | +| `--print-only` | Print the package-manager command without executing it | +| `--package-manager ` | Override package-manager detection | +| `--no-fonts` | Skip the default DejaVu font package | + +```bash +sandbox-agent install desktop --yes +sandbox-agent install desktop --print-only +``` + +Notes: + +- Supported on Linux only. +- The command detects `apt`, `dnf`, or `apk`. +- If the host is not already running as root, the command requires `sudo`. + ## install-agent Install or reinstall a single agent, or every supported agent with `--all`. diff --git a/docs/common-software.mdx b/docs/common-software.mdx new file mode 100644 index 0000000..7997a92 --- /dev/null +++ b/docs/common-software.mdx @@ -0,0 +1,560 @@ +--- +title: "Common Software" +description: "Install browsers, languages, databases, and other tools inside the sandbox." +sidebarTitle: "Common Software" +icon: "box-open" +--- + +The sandbox runs a Debian/Ubuntu base image. You can install software with `apt-get` via the [Process API](/processes) or by customizing your Docker image. This page covers commonly needed packages and how to install them. + +## Browsers + +### Chromium + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "chromium", "chromium-sandbox"], +}); + +// Launch headless +await sdk.runProcess({ + command: "chromium", + args: ["--headless", "--no-sandbox", "--disable-gpu", "https://example.com"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","chromium","chromium-sandbox"]}' +``` + + + +Use `--no-sandbox` when running Chromium inside a container. The container itself provides isolation. + + +### Firefox + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "firefox-esr"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","firefox-esr"]}' +``` + + +### Playwright browsers + +Playwright bundles its own browser binaries. Install the Playwright CLI and let it download browsers for you. + + +```ts TypeScript +await sdk.runProcess({ + command: "npx", + args: ["playwright", "install", "--with-deps", "chromium"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"npx","args":["playwright","install","--with-deps","chromium"]}' +``` + + +--- + +## Languages and runtimes + +### Node.js + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "nodejs", "npm"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","nodejs","npm"]}' +``` + + +For a specific version, use [nvm](https://github.com/nvm-sh/nvm): + +```ts TypeScript +await sdk.runProcess({ + command: "bash", + args: ["-c", "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . ~/.nvm/nvm.sh && nvm install 22"], +}); +``` + +### Python + +Python 3 is typically pre-installed. To add pip and common packages: + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "python3", "python3-pip", "python3-venv"], +}); + +await sdk.runProcess({ + command: "pip3", + args: ["install", "numpy", "pandas", "matplotlib"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","python3","python3-pip","python3-venv"]}' + +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"pip3","args":["install","numpy","pandas","matplotlib"]}' +``` + + +### Go + + +```ts TypeScript +await sdk.runProcess({ + command: "bash", + args: ["-c", "curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz"], +}); + +// Add to PATH for subsequent commands +await sdk.runProcess({ + command: "bash", + args: ["-c", "export PATH=$PATH:/usr/local/go/bin && go version"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"bash","args":["-c","curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz"]}' +``` + + +### Rust + + +```ts TypeScript +await sdk.runProcess({ + command: "bash", + args: ["-c", "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"bash","args":["-c","curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"]}' +``` + + +### Java (OpenJDK) + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "default-jdk"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","default-jdk"]}' +``` + + +### Ruby + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "ruby-full"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","ruby-full"]}' +``` + + +--- + +## Databases + +### PostgreSQL + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "postgresql", "postgresql-client"], +}); + +// Start the service +const proc = await sdk.createProcess({ + command: "bash", + args: ["-c", "su - postgres -c 'pg_ctlcluster 15 main start'"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","postgresql","postgresql-client"]}' +``` + + +### SQLite + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "sqlite3"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","sqlite3"]}' +``` + + +### Redis + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "redis-server"], +}); + +const proc = await sdk.createProcess({ + command: "redis-server", + args: ["--daemonize", "no"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","redis-server"]}' + +curl -X POST "http://127.0.0.1:2468/v1/processes" \ + -H "Content-Type: application/json" \ + -d '{"command":"redis-server","args":["--daemonize","no"]}' +``` + + +### MySQL / MariaDB + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "mariadb-server", "mariadb-client"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","mariadb-server","mariadb-client"]}' +``` + + +--- + +## Build tools + +### Essential build toolchain + +Most compiled software needs the standard build toolchain: + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "build-essential", "cmake", "pkg-config"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","build-essential","cmake","pkg-config"]}' +``` + + +This installs `gcc`, `g++`, `make`, `cmake`, and related tools. + +--- + +## Desktop applications + +These require the [Computer Use](/computer-use) desktop to be started first. + +### LibreOffice + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "libreoffice"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","libreoffice"]}' +``` + + +### GIMP + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "gimp"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","gimp"]}' +``` + + +### VLC + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "vlc"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","vlc"]}' +``` + + +### VS Code (code-server) + + +```ts TypeScript +await sdk.runProcess({ + command: "bash", + args: ["-c", "curl -fsSL https://code-server.dev/install.sh | sh"], +}); + +const proc = await sdk.createProcess({ + command: "code-server", + args: ["--bind-addr", "0.0.0.0:8080", "--auth", "none"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"bash","args":["-c","curl -fsSL https://code-server.dev/install.sh | sh"]}' + +curl -X POST "http://127.0.0.1:2468/v1/processes" \ + -H "Content-Type: application/json" \ + -d '{"command":"code-server","args":["--bind-addr","0.0.0.0:8080","--auth","none"]}' +``` + + +--- + +## CLI tools + +### Git + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "git"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","git"]}' +``` + + +### Docker + + +```ts TypeScript +await sdk.runProcess({ + command: "bash", + args: ["-c", "curl -fsSL https://get.docker.com | sh"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"bash","args":["-c","curl -fsSL https://get.docker.com | sh"]}' +``` + + +### jq + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "jq"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","jq"]}' +``` + + +### tmux + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "tmux"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","tmux"]}' +``` + + +--- + +## Media and graphics + +### FFmpeg + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "ffmpeg"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","ffmpeg"]}' +``` + + +### ImageMagick + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "imagemagick"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","imagemagick"]}' +``` + + +### Poppler (PDF utilities) + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "poppler-utils"], +}); + +// Convert PDF to images +await sdk.runProcess({ + command: "pdftoppm", + args: ["-png", "document.pdf", "output"], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","poppler-utils"]}' +``` + + +--- + +## Pre-installing in a Docker image + +For production use, install software in your Dockerfile instead of at runtime. This avoids repeated downloads and makes startup faster. + +```dockerfile +FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -y \ + chromium \ + firefox-esr \ + nodejs npm \ + python3 python3-pip \ + git curl wget \ + build-essential \ + sqlite3 \ + ffmpeg \ + imagemagick \ + jq \ + && rm -rf /var/lib/apt/lists/* + +RUN pip3 install numpy pandas matplotlib +``` + +See [Docker deployment](/deploy/docker) for how to use custom images with Sandbox Agent. diff --git a/docs/computer-use.mdx b/docs/computer-use.mdx new file mode 100644 index 0000000..fc6b7d0 --- /dev/null +++ b/docs/computer-use.mdx @@ -0,0 +1,859 @@ +--- +title: "Computer Use" +description: "Control a virtual desktop inside the sandbox with mouse, keyboard, screenshots, recordings, and live streaming." +sidebarTitle: "Computer Use" +icon: "desktop" +--- + +Sandbox Agent provides a managed virtual desktop (Xvfb + openbox) that you can control programmatically. This is useful for browser automation, GUI testing, and AI computer-use workflows. + +## Start and stop + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", +}); + +const status = await sdk.startDesktop({ + width: 1920, + height: 1080, + dpi: 96, +}); + +console.log(status.state); // "active" +console.log(status.display); // ":99" + +// When done +await sdk.stopDesktop(); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/start" \ + -H "Content-Type: application/json" \ + -d '{"width":1920,"height":1080,"dpi":96}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/stop" +``` + + +All fields in the start request are optional. Defaults are 1440x900 at 96 DPI. + +### Start request options + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `width` | number | 1440 | Desktop width in pixels | +| `height` | number | 900 | Desktop height in pixels | +| `dpi` | number | 96 | Display DPI | +| `displayNum` | number | 99 | Starting X display number. The runtime probes from this number upward to find an available display. | +| `stateDir` | string | (auto) | Desktop state directory for home, logs, recordings | +| `streamVideoCodec` | string | `"vp8"` | WebRTC video codec (`vp8`, `vp9`, `h264`) | +| `streamAudioCodec` | string | `"opus"` | WebRTC audio codec (`opus`, `g722`) | +| `streamFrameRate` | number | 30 | Streaming frame rate (1-60) | +| `webrtcPortRange` | string | `"59050-59070"` | UDP port range for WebRTC media | +| `recordingFps` | number | 30 | Default recording FPS when not specified in `startDesktopRecording` (1-60) | + +The streaming and recording options configure defaults for the desktop session. They take effect when streaming or recording is started later. + + +```ts TypeScript +const status = await sdk.startDesktop({ + width: 1920, + height: 1080, + streamVideoCodec: "h264", + streamFrameRate: 60, + webrtcPortRange: "59100-59120", + recordingFps: 15, +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/start" \ + -H "Content-Type: application/json" \ + -d '{ + "width": 1920, + "height": 1080, + "streamVideoCodec": "h264", + "streamFrameRate": 60, + "webrtcPortRange": "59100-59120", + "recordingFps": 15 + }' +``` + + +## Status + + +```ts TypeScript +const status = await sdk.getDesktopStatus(); +console.log(status.state); // "inactive" | "active" | "failed" | ... +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/status" +``` + + +## Screenshots + +Capture the full desktop or a specific region. Optionally include the cursor position. + + +```ts TypeScript +// Full screenshot (PNG by default) +const png = await sdk.takeDesktopScreenshot(); + +// JPEG at 70% quality, half scale +const jpeg = await sdk.takeDesktopScreenshot({ + format: "jpeg", + quality: 70, + scale: 0.5, +}); + +// Include cursor overlay +const withCursor = await sdk.takeDesktopScreenshot({ + showCursor: true, +}); + +// Region screenshot +const region = await sdk.takeDesktopRegionScreenshot({ + x: 100, + y: 100, + width: 400, + height: 300, +}); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/screenshot" --output screenshot.png + +curl "http://127.0.0.1:2468/v1/desktop/screenshot?format=jpeg&quality=70&scale=0.5" \ + --output screenshot.jpg + +# Include cursor overlay +curl "http://127.0.0.1:2468/v1/desktop/screenshot?show_cursor=true" \ + --output with_cursor.png + +curl "http://127.0.0.1:2468/v1/desktop/screenshot/region?x=100&y=100&width=400&height=300" \ + --output region.png +``` + + +### Screenshot options + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `format` | string | `"png"` | Output format: `png`, `jpeg`, or `webp` | +| `quality` | number | 85 | Compression quality (1-100, JPEG/WebP only) | +| `scale` | number | 1.0 | Scale factor (0.1-1.0) | +| `showCursor` | boolean | `false` | Composite a crosshair at the cursor position | + +When `showCursor` is enabled, the cursor position is captured at the moment of the screenshot and a red crosshair is drawn at that location. This is useful for AI agents that need to see where the cursor is in the screenshot. + +## Mouse + + +```ts TypeScript +// Get current position +const pos = await sdk.getDesktopMousePosition(); +console.log(pos.x, pos.y); + +// Move +await sdk.moveDesktopMouse({ x: 500, y: 300 }); + +// Click (left by default) +await sdk.clickDesktop({ x: 500, y: 300 }); + +// Right click +await sdk.clickDesktop({ x: 500, y: 300, button: "right" }); + +// Double click +await sdk.clickDesktop({ x: 500, y: 300, clickCount: 2 }); + +// Drag +await sdk.dragDesktopMouse({ + startX: 100, startY: 100, + endX: 400, endY: 400, +}); + +// Scroll +await sdk.scrollDesktop({ x: 500, y: 300, deltaY: -3 }); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/mouse/position" + +curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/click" \ + -H "Content-Type: application/json" \ + -d '{"x":500,"y":300}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/drag" \ + -H "Content-Type: application/json" \ + -d '{"startX":100,"startY":100,"endX":400,"endY":400}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/scroll" \ + -H "Content-Type: application/json" \ + -d '{"x":500,"y":300,"deltaY":-3}' +``` + + +## Keyboard + + +```ts TypeScript +// Type text +await sdk.typeDesktopText({ text: "Hello, world!" }); + +// Press a key with modifiers +await sdk.pressDesktopKey({ + key: "c", + modifiers: { ctrl: true }, +}); + +// Low-level key down/up +await sdk.keyDownDesktop({ key: "Shift_L" }); +await sdk.keyUpDesktop({ key: "Shift_L" }); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/type" \ + -H "Content-Type: application/json" \ + -d '{"text":"Hello, world!"}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/press" \ + -H "Content-Type: application/json" \ + -d '{"key":"c","modifiers":{"ctrl":true}}' +``` + + +## Clipboard + +Read and write the X11 clipboard programmatically. + + +```ts TypeScript +// Read clipboard +const clipboard = await sdk.getDesktopClipboard(); +console.log(clipboard.text); + +// Read primary selection (mouse-selected text) +const primary = await sdk.getDesktopClipboard({ selection: "primary" }); + +// Write to clipboard +await sdk.setDesktopClipboard({ text: "Pasted via API" }); + +// Write to both clipboard and primary selection +await sdk.setDesktopClipboard({ + text: "Synced text", + selection: "both", +}); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/clipboard" + +curl "http://127.0.0.1:2468/v1/desktop/clipboard?selection=primary" + +curl -X POST "http://127.0.0.1:2468/v1/desktop/clipboard" \ + -H "Content-Type: application/json" \ + -d '{"text":"Pasted via API"}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/clipboard" \ + -H "Content-Type: application/json" \ + -d '{"text":"Synced text","selection":"both"}' +``` + + +The `selection` parameter controls which X11 selection to read or write: + +| Value | Description | +|-------|-------------| +| `clipboard` (default) | The standard clipboard (Ctrl+C / Ctrl+V) | +| `primary` | The primary selection (text selected with the mouse) | +| `both` | Write to both clipboard and primary selection (write only) | + +## Display and windows + + +```ts TypeScript +const display = await sdk.getDesktopDisplayInfo(); +console.log(display.resolution); // { width: 1920, height: 1080, dpi: 96 } + +const { windows } = await sdk.listDesktopWindows(); +for (const win of windows) { + console.log(win.title, win.x, win.y, win.width, win.height); +} +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/display/info" + +curl "http://127.0.0.1:2468/v1/desktop/windows" +``` + + +The windows endpoint filters out noise automatically: window manager internals (Openbox), windows with empty titles, and tiny helper windows (under 120x80) are excluded. The currently active/focused window is always included regardless of filters. + +### Focused window + +Get the currently focused window without listing all windows. + + +```ts TypeScript +const focused = await sdk.getDesktopFocusedWindow(); +console.log(focused.title, focused.id); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/windows/focused" +``` + + +Returns 404 if no window currently has focus. + +### Window management + +Focus, move, and resize windows by their X11 window ID. + + +```ts TypeScript +const { windows } = await sdk.listDesktopWindows(); +const win = windows[0]; + +// Bring window to foreground +await sdk.focusDesktopWindow(win.id); + +// Move window +await sdk.moveDesktopWindow(win.id, { x: 100, y: 50 }); + +// Resize window +await sdk.resizeDesktopWindow(win.id, { width: 1280, height: 720 }); +``` + +```bash cURL +# Focus a window +curl -X POST "http://127.0.0.1:2468/v1/desktop/windows/12345/focus" + +# Move a window +curl -X POST "http://127.0.0.1:2468/v1/desktop/windows/12345/move" \ + -H "Content-Type: application/json" \ + -d '{"x":100,"y":50}' + +# Resize a window +curl -X POST "http://127.0.0.1:2468/v1/desktop/windows/12345/resize" \ + -H "Content-Type: application/json" \ + -d '{"width":1280,"height":720}' +``` + + +All three endpoints return the updated window info so you can verify the operation took effect. The window manager may adjust the requested position or size. + +## App launching + +Launch applications or open files/URLs on the desktop without needing to shell out. + + +```ts TypeScript +// Launch an app by name +const result = await sdk.launchDesktopApp({ + app: "firefox", + args: ["--private"], +}); +console.log(result.processId); // "proc_7" + +// Launch and wait for the window to appear +const withWindow = await sdk.launchDesktopApp({ + app: "xterm", + wait: true, +}); +console.log(withWindow.windowId); // "12345" or null if timed out + +// Open a URL with the default handler +const opened = await sdk.openDesktopTarget({ + target: "https://example.com", +}); +console.log(opened.processId); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/launch" \ + -H "Content-Type: application/json" \ + -d '{"app":"firefox","args":["--private"]}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/launch" \ + -H "Content-Type: application/json" \ + -d '{"app":"xterm","wait":true}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/open" \ + -H "Content-Type: application/json" \ + -d '{"target":"https://example.com"}' +``` + + +The returned `processId` can be used with the [Process API](/processes) to read logs (`GET /v1/processes/{id}/logs`) or stop the application (`POST /v1/processes/{id}/stop`). + +When `wait` is `true`, the API polls for up to 5 seconds for a window to appear. If the window appears, its ID is returned in `windowId`. If it times out, `windowId` is `null` but the process is still running. + + +**Launch/Open vs the Process API:** Both `launch` and `open` are convenience wrappers around the [Process API](/processes). They create managed processes (with `owner: "desktop"`) that you can inspect, log, and stop through the same Process endpoints. The difference is that `launch` validates the binary exists in PATH first and can optionally wait for a window to appear, while `open` delegates to the system default handler (`xdg-open`). Use the Process API directly when you need full control over command, environment, working directory, or restart policies. + + +## Recording + +Record the desktop to MP4. + + +```ts TypeScript +const recording = await sdk.startDesktopRecording({ fps: 30 }); +console.log(recording.id); + +// ... do things ... + +const stopped = await sdk.stopDesktopRecording(); + +// List all recordings +const { recordings } = await sdk.listDesktopRecordings(); + +// Download +const mp4 = await sdk.downloadDesktopRecording(recording.id); + +// Clean up +await sdk.deleteDesktopRecording(recording.id); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/recording/start" \ + -H "Content-Type: application/json" \ + -d '{"fps":30}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/recording/stop" + +curl "http://127.0.0.1:2468/v1/desktop/recordings" + +curl "http://127.0.0.1:2468/v1/desktop/recordings/rec_1/download" --output recording.mp4 + +curl -X DELETE "http://127.0.0.1:2468/v1/desktop/recordings/rec_1" +``` + + +## Desktop processes + +The desktop runtime manages several background processes (Xvfb, openbox, neko, ffmpeg). These are all registered with the general [Process API](/processes) under the `desktop` owner, so you can inspect logs, check status, and troubleshoot using the same tools you use for any other managed process. + + +```ts TypeScript +// List all processes, including desktop-owned ones +const { processes } = await sdk.listProcesses(); + +const desktopProcs = processes.filter((p) => p.owner === "desktop"); +for (const p of desktopProcs) { + console.log(p.id, p.command, p.status); +} + +// Read logs from a specific desktop process +const logs = await sdk.getProcessLogs(desktopProcs[0].id, { tail: 50 }); +for (const entry of logs.entries) { + console.log(entry.stream, atob(entry.data)); +} +``` + +```bash cURL +# List all processes (desktop processes have owner: "desktop") +curl "http://127.0.0.1:2468/v1/processes" + +# Get logs from a specific desktop process +curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50" +``` + + +The desktop status endpoint also includes a summary of running processes: + + +```ts TypeScript +const status = await sdk.getDesktopStatus(); +for (const proc of status.processes) { + console.log(proc.name, proc.pid, proc.running); +} +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/status" +# Response includes: processes: [{ name: "Xvfb", pid: 123, running: true }, ...] +``` + + +| Process | Role | Restart policy | +|---------|------|---------------| +| Xvfb | Virtual X11 framebuffer | Auto-restart while desktop is active | +| openbox | Window manager | Auto-restart while desktop is active | +| neko | WebRTC streaming server (started by `startDesktopStream`) | No auto-restart | +| ffmpeg | Screen recorder (started by `startDesktopRecording`) | No auto-restart | + +## Live streaming + +Start a WebRTC stream for real-time desktop viewing in a browser. + + +```ts TypeScript +await sdk.startDesktopStream(); + +// Check stream status +const status = await sdk.getDesktopStreamStatus(); +console.log(status.active); // true +console.log(status.processId); // "proc_5" + +// Connect via the React DesktopViewer component or +// use the WebSocket signaling endpoint directly +// at ws://127.0.0.1:2468/v1/desktop/stream/signaling + +await sdk.stopDesktopStream(); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/start" + +# Check stream status +curl "http://127.0.0.1:2468/v1/desktop/stream/status" + +# Connect to ws://127.0.0.1:2468/v1/desktop/stream/signaling for WebRTC signaling + +curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/stop" +``` + + +For a drop-in React component, see [React Components](/react-components). + +## API reference + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/v1/desktop/start` | Start the desktop runtime | +| `POST` | `/v1/desktop/stop` | Stop the desktop runtime | +| `GET` | `/v1/desktop/status` | Get desktop runtime status | +| `GET` | `/v1/desktop/screenshot` | Capture full desktop screenshot | +| `GET` | `/v1/desktop/screenshot/region` | Capture a region screenshot | +| `GET` | `/v1/desktop/mouse/position` | Get current mouse position | +| `POST` | `/v1/desktop/mouse/move` | Move the mouse | +| `POST` | `/v1/desktop/mouse/click` | Click the mouse | +| `POST` | `/v1/desktop/mouse/down` | Press mouse button down | +| `POST` | `/v1/desktop/mouse/up` | Release mouse button | +| `POST` | `/v1/desktop/mouse/drag` | Drag from one point to another | +| `POST` | `/v1/desktop/mouse/scroll` | Scroll at a position | +| `POST` | `/v1/desktop/keyboard/type` | Type text | +| `POST` | `/v1/desktop/keyboard/press` | Press a key with optional modifiers | +| `POST` | `/v1/desktop/keyboard/down` | Press a key down (hold) | +| `POST` | `/v1/desktop/keyboard/up` | Release a key | +| `GET` | `/v1/desktop/display/info` | Get display info | +| `GET` | `/v1/desktop/windows` | List visible windows | +| `GET` | `/v1/desktop/windows/focused` | Get focused window info | +| `POST` | `/v1/desktop/windows/{id}/focus` | Focus a window | +| `POST` | `/v1/desktop/windows/{id}/move` | Move a window | +| `POST` | `/v1/desktop/windows/{id}/resize` | Resize a window | +| `GET` | `/v1/desktop/clipboard` | Read clipboard contents | +| `POST` | `/v1/desktop/clipboard` | Write to clipboard | +| `POST` | `/v1/desktop/launch` | Launch an application | +| `POST` | `/v1/desktop/open` | Open a file or URL | +| `POST` | `/v1/desktop/recording/start` | Start recording | +| `POST` | `/v1/desktop/recording/stop` | Stop recording | +| `GET` | `/v1/desktop/recordings` | List recordings | +| `GET` | `/v1/desktop/recordings/{id}` | Get recording metadata | +| `GET` | `/v1/desktop/recordings/{id}/download` | Download recording | +| `DELETE` | `/v1/desktop/recordings/{id}` | Delete recording | +| `POST` | `/v1/desktop/stream/start` | Start WebRTC streaming | +| `POST` | `/v1/desktop/stream/stop` | Stop WebRTC streaming | +| `GET` | `/v1/desktop/stream/status` | Get stream status | +| `GET` | `/v1/desktop/stream/signaling` | WebSocket for WebRTC signaling | + +### TypeScript SDK methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `startDesktop(request?)` | `DesktopStatusResponse` | Start the desktop | +| `stopDesktop()` | `DesktopStatusResponse` | Stop the desktop | +| `getDesktopStatus()` | `DesktopStatusResponse` | Get desktop status | +| `takeDesktopScreenshot(query?)` | `Uint8Array` | Capture screenshot | +| `takeDesktopRegionScreenshot(query)` | `Uint8Array` | Capture region screenshot | +| `getDesktopMousePosition()` | `DesktopMousePositionResponse` | Get mouse position | +| `moveDesktopMouse(request)` | `DesktopMousePositionResponse` | Move mouse | +| `clickDesktop(request)` | `DesktopMousePositionResponse` | Click mouse | +| `mouseDownDesktop(request)` | `DesktopMousePositionResponse` | Mouse button down | +| `mouseUpDesktop(request)` | `DesktopMousePositionResponse` | Mouse button up | +| `dragDesktopMouse(request)` | `DesktopMousePositionResponse` | Drag mouse | +| `scrollDesktop(request)` | `DesktopMousePositionResponse` | Scroll | +| `typeDesktopText(request)` | `DesktopActionResponse` | Type text | +| `pressDesktopKey(request)` | `DesktopActionResponse` | Press key | +| `keyDownDesktop(request)` | `DesktopActionResponse` | Key down | +| `keyUpDesktop(request)` | `DesktopActionResponse` | Key up | +| `getDesktopDisplayInfo()` | `DesktopDisplayInfoResponse` | Get display info | +| `listDesktopWindows()` | `DesktopWindowListResponse` | List windows | +| `getDesktopFocusedWindow()` | `DesktopWindowInfo` | Get focused window | +| `focusDesktopWindow(id)` | `DesktopWindowInfo` | Focus a window | +| `moveDesktopWindow(id, request)` | `DesktopWindowInfo` | Move a window | +| `resizeDesktopWindow(id, request)` | `DesktopWindowInfo` | Resize a window | +| `getDesktopClipboard(query?)` | `DesktopClipboardResponse` | Read clipboard | +| `setDesktopClipboard(request)` | `DesktopActionResponse` | Write clipboard | +| `launchDesktopApp(request)` | `DesktopLaunchResponse` | Launch an app | +| `openDesktopTarget(request)` | `DesktopOpenResponse` | Open file/URL | +| `startDesktopRecording(request?)` | `DesktopRecordingInfo` | Start recording | +| `stopDesktopRecording()` | `DesktopRecordingInfo` | Stop recording | +| `listDesktopRecordings()` | `DesktopRecordingListResponse` | List recordings | +| `getDesktopRecording(id)` | `DesktopRecordingInfo` | Get recording | +| `downloadDesktopRecording(id)` | `Uint8Array` | Download recording | +| `deleteDesktopRecording(id)` | `void` | Delete recording | +| `startDesktopStream()` | `DesktopStreamStatusResponse` | Start streaming | +| `stopDesktopStream()` | `DesktopStreamStatusResponse` | Stop streaming | +| `getDesktopStreamStatus()` | `DesktopStreamStatusResponse` | Stream status | + +## Customizing the desktop environment + +The desktop runs inside the sandbox filesystem, so you can customize it using the [File System](/file-system) API before or after starting the desktop. The desktop HOME directory is located at `~/.local/state/sandbox-agent/desktop/home` (or `$XDG_STATE_HOME/sandbox-agent/desktop/home` if `XDG_STATE_HOME` is set). + +All configuration files below are written to paths relative to this HOME directory. + +### Window manager (openbox) + +The desktop uses [openbox](http://openbox.org/) as its window manager. You can customize its behavior, theme, and keyboard shortcuts by writing an `rc.xml` config file. + + +```ts TypeScript +const openboxConfig = ` + + + Clearlooks + NLIMC + DejaVu Sans10 + + 1 + + + + +`; + +await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" }); +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/rc.xml" }, + openboxConfig, +); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox" + +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/rc.xml" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @rc.xml +``` + + +### Autostart programs + +Openbox runs scripts in `~/.config/openbox/autostart` on startup. Use this to launch applications, set the background, or configure the environment. + + +```ts TypeScript +const autostart = `#!/bin/sh +# Set a solid background color +xsetroot -solid "#1e1e2e" & + +# Launch a terminal +xterm -geometry 120x40+50+50 & + +# Launch a browser +firefox --no-remote & +`; + +await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" }); +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" }, + autostart, +); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox" + +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @autostart.sh +``` + + + +The autostart script runs when openbox starts, which happens during `startDesktop()`. Write the autostart file before calling `startDesktop()` for it to take effect. + + +### Background + +There is no wallpaper set by default (the background is the X root window default). You can set it using `xsetroot` in the autostart script (as shown above), or use `feh` if you need an image: + + +```ts TypeScript +// Upload a wallpaper image +import fs from "node:fs"; + +const wallpaper = await fs.promises.readFile("./wallpaper.png"); +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/wallpaper.png" }, + wallpaper, +); + +// Set the autostart to apply it +const autostart = `#!/bin/sh +feh --bg-fill ~/wallpaper.png & +`; + +await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" }); +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" }, + autostart, +); +``` + +```bash cURL +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/wallpaper.png" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @wallpaper.png + +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @autostart.sh +``` + + + +`feh` is not installed by default. Install it via the [Process API](/processes) before starting the desktop: `await sdk.runProcess({ command: "apt-get", args: ["install", "-y", "feh"] })`. + + +### Fonts + +Only `fonts-dejavu-core` is installed by default. To add more fonts, install them with your system package manager or copy font files into the sandbox: + + +```ts TypeScript +// Install a font package +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "fonts-noto", "fonts-liberation"], +}); + +// Or copy a custom font file +import fs from "node:fs"; + +const font = await fs.promises.readFile("./CustomFont.ttf"); +await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.local/share/fonts" }); +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/.local/share/fonts/CustomFont.ttf" }, + font, +); + +// Rebuild the font cache +await sdk.runProcess({ command: "fc-cache", args: ["-fv"] }); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","fonts-noto","fonts-liberation"]}' + +curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.local/share/fonts" + +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.local/share/fonts/CustomFont.ttf" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @CustomFont.ttf + +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"fc-cache","args":["-fv"]}' +``` + + +### Cursor theme + + +```ts TypeScript +await sdk.runProcess({ + command: "apt-get", + args: ["install", "-y", "dmz-cursor-theme"], +}); + +const xresources = `Xcursor.theme: DMZ-White\nXcursor.size: 24\n`; +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/.Xresources" }, + xresources, +); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ + -H "Content-Type: application/json" \ + -d '{"command":"apt-get","args":["install","-y","dmz-cursor-theme"]}' + +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.Xresources" \ + -H "Content-Type: application/octet-stream" \ + --data-binary 'Xcursor.theme: DMZ-White\nXcursor.size: 24' +``` + + + +Run `xrdb -merge ~/.Xresources` (via the autostart or process API) after writing the file for changes to take effect. + + +### Shell and terminal + +No terminal emulator or shell is launched by default. Add one to the openbox autostart: + +```sh +# In ~/.config/openbox/autostart +xterm -geometry 120x40+50+50 & +``` + +To use a different shell, set the `SHELL` environment variable in your Dockerfile or install your preferred shell and configure the terminal to use it. + +### GTK theme + +Applications using GTK will pick up settings from `~/.config/gtk-3.0/settings.ini`: + + +```ts TypeScript +const gtkSettings = `[Settings] +gtk-theme-name=Adwaita +gtk-icon-theme-name=Adwaita +gtk-font-name=DejaVu Sans 10 +gtk-cursor-theme-name=DMZ-White +gtk-cursor-theme-size=24 +`; + +await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0" }); +await sdk.writeFsFile( + { path: "~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0/settings.ini" }, + gtkSettings, +); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0" + +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0/settings.ini" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @settings.ini +``` + + +### Summary of configuration paths + +All paths are relative to the desktop HOME directory (`~/.local/state/sandbox-agent/desktop/home`). + +| What | Path | Notes | +|------|------|-------| +| Openbox config | `.config/openbox/rc.xml` | Window manager theme, keybindings, behavior | +| Autostart | `.config/openbox/autostart` | Shell script run on desktop start | +| Custom fonts | `.local/share/fonts/` | TTF/OTF files, run `fc-cache -fv` after | +| Cursor theme | `.Xresources` | Requires `xrdb -merge` to apply | +| GTK 3 settings | `.config/gtk-3.0/settings.ini` | Theme, icons, fonts for GTK apps | +| Wallpaper | Any path, referenced from autostart | Requires `feh` or similar tool | diff --git a/docs/deploy/boxlite.mdx b/docs/deploy/boxlite.mdx index 115d8b8..8c02bb4 100644 --- a/docs/deploy/boxlite.mdx +++ b/docs/deploy/boxlite.mdx @@ -20,7 +20,7 @@ that BoxLite can load directly (BoxLite has its own image store separate from Do ```dockerfile FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent codex ``` diff --git a/docs/deploy/cloudflare.mdx b/docs/deploy/cloudflare.mdx index 1cecdd7..c0370e4 100644 --- a/docs/deploy/cloudflare.mdx +++ b/docs/deploy/cloudflare.mdx @@ -25,7 +25,7 @@ cd my-sandbox ```dockerfile FROM cloudflare/sandbox:0.7.0 -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex EXPOSE 8000 @@ -36,7 +36,7 @@ EXPOSE 8000 For standalone scripts, use the `cloudflare` provider: ```bash -npm install sandbox-agent@0.3.x @cloudflare/sandbox +npm install sandbox-agent@0.4.x @cloudflare/sandbox ``` ```typescript diff --git a/docs/deploy/computesdk.mdx b/docs/deploy/computesdk.mdx index 1adfffe..601d9c7 100644 --- a/docs/deploy/computesdk.mdx +++ b/docs/deploy/computesdk.mdx @@ -14,7 +14,7 @@ description: "Deploy Sandbox Agent using ComputeSDK's provider-agnostic sandbox ## TypeScript example ```bash -npm install sandbox-agent@0.3.x computesdk +npm install sandbox-agent@0.4.x computesdk ``` ```typescript @@ -27,7 +27,11 @@ if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY const sdk = await SandboxAgent.start({ sandbox: computesdk({ - create: { envs }, + create: { + envs, + image: process.env.COMPUTESDK_IMAGE, + templateId: process.env.COMPUTESDK_TEMPLATE_ID, + }, }), }); @@ -43,6 +47,7 @@ try { ``` The `computesdk` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. ComputeSDK routes to your configured provider behind the scenes. +The `create` option now forwards the full ComputeSDK sandbox-create payload, including provider-specific fields such as `image` and `templateId` when the selected provider supports them. Before calling `SandboxAgent.start()`, configure ComputeSDK with your provider: diff --git a/docs/deploy/daytona.mdx b/docs/deploy/daytona.mdx index cc1277b..e546bef 100644 --- a/docs/deploy/daytona.mdx +++ b/docs/deploy/daytona.mdx @@ -16,7 +16,7 @@ See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/). ## TypeScript example ```bash -npm install sandbox-agent@0.3.x @daytonaio/sdk +npm install sandbox-agent@0.4.x @daytonaio/sdk ``` ```typescript @@ -44,7 +44,7 @@ try { } ``` -The `daytona` provider uses the `rivetdev/sandbox-agent:0.4.1-rc.1-full` image by default and starts the server automatically. +The `daytona` provider uses the `rivetdev/sandbox-agent:0.4.2-full` image by default and starts the server automatically. ## Using snapshots for faster startup @@ -61,7 +61,7 @@ if (!hasSnapshot) { name: SNAPSHOT, image: Image.base("ubuntu:22.04").runCommands( "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh", "sandbox-agent install-agent claude", "sandbox-agent install-agent codex", ), diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 674c2d5..c5a3432 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -15,43 +15,64 @@ Run the published full image with all supported agents pre-installed: docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - rivetdev/sandbox-agent:0.4.1-rc.1-full \ + rivetdev/sandbox-agent:0.4.2-full \ server --no-token --host 0.0.0.0 --port 3000 ``` -The `0.4.1-rc.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. +The `0.4.2-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. -## TypeScript with the Docker provider +If you also want the desktop API inside the container, install desktop dependencies before starting the server: ```bash -npm install sandbox-agent@0.3.x dockerode get-port +docker run --rm -p 3000:3000 \ + -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ + -e OPENAI_API_KEY="$OPENAI_API_KEY" \ + node:22-bookworm-slim sh -c "\ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6 && \ + rm -rf /var/lib/apt/lists/* && \ + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh && \ + sandbox-agent install desktop --yes && \ + sandbox-agent server --no-token --host 0.0.0.0 --port 3000" ``` -```typescript -import { SandboxAgent } from "sandbox-agent"; -import { docker } from "sandbox-agent/docker"; +In a Dockerfile: -const sdk = await SandboxAgent.start({ - sandbox: docker({ - env: [ - `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, - `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, - ].filter(Boolean), - }), +```dockerfile +RUN sandbox-agent install desktop --yes +``` + +## TypeScript with dockerode + +```typescript +import Docker from "dockerode"; +import { SandboxAgent } from "sandbox-agent"; + +const docker = new Docker(); +const PORT = 3000; + +const container = await docker.createContainer({ + Image: "rivetdev/sandbox-agent:0.4.2-full", + Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], + Env: [ + `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, + `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, + `CODEX_API_KEY=${process.env.CODEX_API_KEY}`, + ].filter(Boolean), + ExposedPorts: { [`${PORT}/tcp`]: {} }, + HostConfig: { + AutoRemove: true, + PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, + }, }); -try { - const session = await sdk.createSession({ agent: "codex" }); - await session.prompt([{ type: "text", text: "Summarize this repository." }]); -} finally { - await sdk.destroySandbox(); -} -``` +await container.start(); -The `docker` provider uses the `rivetdev/sandbox-agent:0.4.1-rc.1-full` image by default. Override with `image`: +const baseUrl = `http://127.0.0.1:${PORT}`; +const sdk = await SandboxAgent.connect({ baseUrl }); -```typescript -docker({ image: "my-custom-image:latest" }) +const session = await sdk.createSession({ agent: "codex" }); +await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` ## Building a custom image with everything preinstalled @@ -65,7 +86,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ bash ca-certificates curl git && \ rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh && \ sandbox-agent install-agent --all RUN useradd -m -s /bin/bash sandbox diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index e6465f2..225cfdc 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -11,7 +11,7 @@ description: "Deploy Sandbox Agent inside an E2B sandbox." ## TypeScript example ```bash -npm install sandbox-agent@0.3.x @e2b/code-interpreter +npm install sandbox-agent@0.4.x @e2b/code-interpreter ``` ```typescript @@ -21,9 +21,11 @@ import { e2b } from "sandbox-agent/e2b"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const template = process.env.E2B_TEMPLATE; const sdk = await SandboxAgent.start({ sandbox: e2b({ + template, create: { envs }, }), }); @@ -41,7 +43,10 @@ try { The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. Sandboxes pause by default instead of being deleted, and reconnecting with the same `sandboxId` resumes them automatically. +Pass `template` when you want to start from a custom E2B template alias or template ID. E2B base-image selection happens when you build the template, then `sandbox-agent/e2b` uses that template at sandbox creation time. + ## Faster cold starts For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. -See [E2B Custom Templates](https://e2b.dev/docs/sandbox-template). +Build System 2.0 also lets you choose the template's base image in code. +See [E2B Custom Templates](https://e2b.dev/docs/sandbox-template) and [E2B Base Images](https://e2b.dev/docs/template/base-image). diff --git a/docs/deploy/foundry-self-hosting.mdx b/docs/deploy/foundry-self-hosting.mdx deleted file mode 100644 index 8fd43ae..0000000 --- a/docs/deploy/foundry-self-hosting.mdx +++ /dev/null @@ -1,155 +0,0 @@ ---- -title: "Foundry Self-Hosting" -description: "Environment, credentials, and deployment setup for Sandbox Agent Foundry auth, GitHub, and billing." ---- - -This guide documents the deployment contract for the Foundry product surface: app auth, GitHub onboarding, repository import, and billing. - -It also covers the local-development bootstrap that uses `.env.development` only when `NODE_ENV=development`. - -## Local Development - -For backend local development, the Foundry backend now supports a development-only dotenv bootstrap: - -- It loads `.env.development.local` and `.env.development` -- It does this **only** when `NODE_ENV=development` -- It does **not** load dotenv files in production - -The example file lives at [`/.env.development.example`](https://github.com/rivet-dev/sandbox-agent/blob/main/.env.development.example). - -To use it locally: - -```bash -cp .env.development.example .env.development -``` - -Run the backend with: - -```bash -just foundry-backend-start -``` - -That recipe sets `NODE_ENV=development`, which enables the dotenv loader. - -### Local Defaults - -These values can be safely defaulted for local development: - -- `APP_URL=http://localhost:4173` -- `BETTER_AUTH_URL=http://localhost:7741` -- `BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me` -- `GITHUB_REDIRECT_URI=http://localhost:7741/v1/auth/callback/github` - -These should be treated as development-only values. - -## Production Environment - -For production or self-hosting, set these as real environment variables in your deployment platform. Do not rely on dotenv file loading. - -### App/Auth - -| Variable | Required | Notes | -|---|---:|---| -| `APP_URL` | Yes | Public frontend origin | -| `BETTER_AUTH_URL` | Yes | Public auth base URL | -| `BETTER_AUTH_SECRET` | Yes | Strong random secret for auth/session signing | - -### GitHub OAuth - -| Variable | Required | Notes | -|---|---:|---| -| `GITHUB_CLIENT_ID` | Yes | GitHub OAuth app client id | -| `GITHUB_CLIENT_SECRET` | Yes | GitHub OAuth app client secret | -| `GITHUB_REDIRECT_URI` | Yes | GitHub OAuth callback URL | - -Use GitHub OAuth for: - -- user sign-in -- user identity -- org selection -- access to the signed-in user’s GitHub context - -## GitHub App - -If your Foundry deployment uses GitHub App-backed organization install and repo import, also configure: - -| Variable | Required | Notes | -|---|---:|---| -| `GITHUB_APP_ID` | Yes | GitHub App id | -| `GITHUB_APP_CLIENT_ID` | Yes | GitHub App client id | -| `GITHUB_APP_CLIENT_SECRET` | Yes | GitHub App client secret | -| `GITHUB_APP_PRIVATE_KEY` | Yes | PEM private key for installation auth | - -For `.env.development` and `.env.development.local`, store `GITHUB_APP_PRIVATE_KEY` as a quoted single-line value with `\n` escapes instead of raw multi-line PEM text. - -Recommended GitHub App permissions: - -- Repository `Metadata: Read` -- Repository `Contents: Read & Write` -- Repository `Pull requests: Read & Write` -- Repository `Checks: Read` -- Repository `Commit statuses: Read` - -Set the webhook URL to `https:///v1/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`. - -This is required, not optional. Foundry depends on GitHub App webhook delivery for installation lifecycle changes, repo access changes, and ongoing repo / pull request sync. If the GitHub App is not installed for the workspace, or webhook delivery is misconfigured, Foundry will remain in an install / reconnect state and core GitHub-backed functionality will not work correctly. - -Recommended webhook subscriptions: - -- `installation` -- `installation_repositories` -- `pull_request` -- `pull_request_review` -- `pull_request_review_comment` -- `push` -- `create` -- `delete` -- `check_suite` -- `check_run` -- `status` - -Use the GitHub App for: - -- installation/reconnect state -- org repo import -- repository sync -- PR creation and updates - -Use GitHub OAuth for: - -- who the user is -- which orgs they can choose - -## Stripe - -For live billing, configure: - -| Variable | Required | Notes | -|---|---:|---| -| `STRIPE_SECRET_KEY` | Yes | Server-side Stripe secret key | -| `STRIPE_PUBLISHABLE_KEY` | Yes | Client-side Stripe publishable key | -| `STRIPE_WEBHOOK_SECRET` | Yes | Signing secret for billing webhooks | -| `STRIPE_PRICE_TEAM` | Yes | Stripe price id for the Team plan checkout session | - -Stripe should own: - -- hosted checkout -- billing portal -- subscription status -- invoice history -- webhook-driven state sync - -## Mock Invariant - -Foundry’s mock client path should continue to work end to end even when the real auth/GitHub/Stripe path exists. - -That includes: - -- sign-in -- org selection/import -- settings -- billing UI -- workspace/task/session flow -- seat accrual - -Use mock mode for deterministic UI review and local product development. Use the real env-backed path for integration and self-hosting. diff --git a/docs/deploy/local.mdx b/docs/deploy/local.mdx index 90e2ba6..6ecdb09 100644 --- a/docs/deploy/local.mdx +++ b/docs/deploy/local.mdx @@ -9,7 +9,7 @@ For local development, run Sandbox Agent directly on your machine. ```bash # Install -curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh # Run sandbox-agent server --no-token --host 127.0.0.1 --port 2468 @@ -20,12 +20,12 @@ Or with npm/Bun: ```bash - npx @sandbox-agent/cli@0.3.x server --no-token --host 127.0.0.1 --port 2468 + npx @sandbox-agent/cli@0.4.x server --no-token --host 127.0.0.1 --port 2468 ``` ```bash - bunx @sandbox-agent/cli@0.3.x server --no-token --host 127.0.0.1 --port 2468 + bunx @sandbox-agent/cli@0.4.x server --no-token --host 127.0.0.1 --port 2468 ``` diff --git a/docs/deploy/modal.mdx b/docs/deploy/modal.mdx index 02a3828..5850fd8 100644 --- a/docs/deploy/modal.mdx +++ b/docs/deploy/modal.mdx @@ -11,7 +11,7 @@ description: "Deploy Sandbox Agent inside a Modal sandbox." ## TypeScript example ```bash -npm install sandbox-agent@0.3.x modal +npm install sandbox-agent@0.4.x modal ``` ```typescript @@ -21,9 +21,11 @@ import { modal } from "sandbox-agent/modal"; const secrets: Record = {}; if (process.env.ANTHROPIC_API_KEY) secrets.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) secrets.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const baseImage = process.env.MODAL_BASE_IMAGE ?? "node:22-slim"; const sdk = await SandboxAgent.start({ sandbox: modal({ + image: baseImage, create: { secrets }, }), }); @@ -40,6 +42,7 @@ try { ``` The `modal` provider handles app creation, image building, sandbox provisioning, agent installation, server startup, and tunnel networking automatically. +Set `image` to change the base Docker image before Sandbox Agent and its agent binaries are layered on top. You can also pass a prebuilt Modal `Image` object. ## Faster cold starts diff --git a/docs/deploy/vercel.mdx b/docs/deploy/vercel.mdx index db97236..ec931d8 100644 --- a/docs/deploy/vercel.mdx +++ b/docs/deploy/vercel.mdx @@ -11,7 +11,7 @@ description: "Deploy Sandbox Agent inside a Vercel Sandbox." ## TypeScript example ```bash -npm install sandbox-agent@0.3.x @vercel/sandbox +npm install sandbox-agent@0.4.x @vercel/sandbox ``` ```typescript diff --git a/docs/docs.json b/docs/docs.json index 16620fe..dbcc407 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,6 +1,6 @@ { "$schema": "https://mintlify.com/docs.json", - "theme": "willow", + "theme": "mint", "name": "Sandbox Agent SDK", "appearance": { "default": "dark", @@ -8,8 +8,8 @@ }, "colors": { "primary": "#ff4f00", - "light": "#ff4f00", - "dark": "#ff4f00" + "light": "#ff6a2a", + "dark": "#cc3f00" }, "favicon": "/favicon.svg", "logo": { @@ -25,17 +25,13 @@ }, "navbar": { "links": [ - { - "label": "Gigacode", - "icon": "terminal", - "href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" - }, { "label": "Discord", "icon": "discord", "href": "https://discord.gg/auCecybynK" }, { + "label": "GitHub", "type": "github", "href": "https://github.com/rivet-dev/sandbox-agent" } @@ -87,15 +83,12 @@ }, { "group": "System", - "pages": ["file-system", "processes"] - }, - { - "group": "Orchestration", - "pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"] + "pages": ["file-system", "processes", "computer-use", "common-software"] }, { "group": "Reference", "pages": [ + "troubleshooting", "architecture", "cli", "inspector", @@ -127,5 +120,11 @@ ] } ] - } + }, + "__removed": [ + { + "group": "Orchestration", + "pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"] + } + ] } diff --git a/docs/gigacode.mdx b/docs/gigacode.mdx deleted file mode 100644 index ccc9e39..0000000 --- a/docs/gigacode.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Gigacode -url: "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" ---- - - diff --git a/docs/inspector.mdx b/docs/inspector.mdx index cc5f3d0..1412c21 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -35,6 +35,7 @@ console.log(url); - Prompt testing - Request/response debugging - Interactive permission prompts (approve, always-allow, or reject tool-use requests) +- Desktop panel for status, remediation, start/stop, and screenshot refresh - Process management (create, stop, kill, delete, view logs) - Interactive PTY terminal for tty processes - One-shot command execution @@ -50,3 +51,16 @@ console.log(url); The Inspector includes an embedded Ghostty-based terminal for interactive tty processes. The UI uses the SDK's high-level `connectProcessTerminal(...)` wrapper via the shared `@sandbox-agent/react` `ProcessTerminal` component. + +## Desktop panel + +The `Desktop` panel shows the current desktop runtime state, missing dependencies, +the suggested install command, last error details, process/log paths, and the +latest captured screenshot. + +Use it to: + +- Check whether desktop dependencies are installed +- Start or stop the managed desktop runtime +- Refresh desktop status +- Capture a fresh screenshot on demand diff --git a/docs/openapi.json b/docs/openapi.json index 7f42f7c..3624707 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.4.1-rc.1" + "version": "0.4.2" }, "servers": [ { @@ -628,6 +628,1814 @@ } } }, + "/v1/desktop/clipboard": { + "get": { + "tags": ["v1"], + "summary": "Read the desktop clipboard.", + "description": "Returns the current text content of the X11 clipboard.", + "operationId": "get_v1_desktop_clipboard", + "parameters": [ + { + "name": "selection", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Clipboard contents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopClipboardResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Clipboard read failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": ["v1"], + "summary": "Write to the desktop clipboard.", + "description": "Sets the text content of the X11 clipboard.", + "operationId": "post_v1_desktop_clipboard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopClipboardWriteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Clipboard updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopActionResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Clipboard write failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/display/info": { + "get": { + "tags": ["v1"], + "summary": "Get desktop display information.", + "description": "Performs a health-gated display query against the managed desktop and\nreturns the current display identifier and resolution.", + "operationId": "get_v1_desktop_display_info", + "responses": { + "200": { + "description": "Desktop display information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopDisplayInfoResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Desktop runtime health or display query failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/keyboard/down": { + "post": { + "tags": ["v1"], + "summary": "Press and hold a desktop keyboard key.", + "description": "Performs a health-gated `xdotool keydown` operation against the managed\ndesktop.", + "operationId": "post_v1_desktop_keyboard_down", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopKeyboardDownRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop keyboard action result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopActionResponse" + } + } + } + }, + "400": { + "description": "Invalid keyboard down request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/keyboard/press": { + "post": { + "tags": ["v1"], + "summary": "Press a desktop keyboard shortcut.", + "description": "Performs a health-gated `xdotool key` operation against the managed\ndesktop.", + "operationId": "post_v1_desktop_keyboard_press", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopKeyboardPressRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop keyboard action result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopActionResponse" + } + } + } + }, + "400": { + "description": "Invalid keyboard press request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/keyboard/type": { + "post": { + "tags": ["v1"], + "summary": "Type desktop keyboard text.", + "description": "Performs a health-gated `xdotool type` operation against the managed\ndesktop.", + "operationId": "post_v1_desktop_keyboard_type", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopKeyboardTypeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop keyboard action result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopActionResponse" + } + } + } + }, + "400": { + "description": "Invalid keyboard type request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/keyboard/up": { + "post": { + "tags": ["v1"], + "summary": "Release a desktop keyboard key.", + "description": "Performs a health-gated `xdotool keyup` operation against the managed\ndesktop.", + "operationId": "post_v1_desktop_keyboard_up", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopKeyboardUpRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop keyboard action result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopActionResponse" + } + } + } + }, + "400": { + "description": "Invalid keyboard up request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/launch": { + "post": { + "tags": ["v1"], + "summary": "Launch a desktop application.", + "description": "Launches an application by name on the managed desktop, optionally waiting\nfor its window to appear.", + "operationId": "post_v1_desktop_launch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopLaunchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Application launched", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopLaunchResponse" + } + } + } + }, + "404": { + "description": "Application not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/click": { + "post": { + "tags": ["v1"], + "summary": "Click on the desktop.", + "description": "Performs a health-gated pointer move and click against the managed desktop\nand returns the resulting mouse position.", + "operationId": "post_v1_desktop_mouse_click", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMouseClickRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop mouse position after click", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "400": { + "description": "Invalid mouse click request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/down": { + "post": { + "tags": ["v1"], + "summary": "Press and hold a desktop mouse button.", + "description": "Performs a health-gated optional pointer move followed by `xdotool mousedown`\nand returns the resulting mouse position.", + "operationId": "post_v1_desktop_mouse_down", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMouseDownRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop mouse position after button press", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "400": { + "description": "Invalid mouse down request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/drag": { + "post": { + "tags": ["v1"], + "summary": "Drag the desktop mouse.", + "description": "Performs a health-gated drag gesture against the managed desktop and\nreturns the resulting mouse position.", + "operationId": "post_v1_desktop_mouse_drag", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMouseDragRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop mouse position after drag", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "400": { + "description": "Invalid mouse drag request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/move": { + "post": { + "tags": ["v1"], + "summary": "Move the desktop mouse.", + "description": "Performs a health-gated absolute pointer move on the managed desktop and\nreturns the resulting mouse position.", + "operationId": "post_v1_desktop_mouse_move", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMouseMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop mouse position after move", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "400": { + "description": "Invalid mouse move request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/position": { + "get": { + "tags": ["v1"], + "summary": "Get the current desktop mouse position.", + "description": "Performs a health-gated mouse position query against the managed desktop.", + "operationId": "get_v1_desktop_mouse_position", + "responses": { + "200": { + "description": "Desktop mouse position", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input check failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/scroll": { + "post": { + "tags": ["v1"], + "summary": "Scroll the desktop mouse wheel.", + "description": "Performs a health-gated scroll gesture at the requested coordinates and\nreturns the resulting mouse position.", + "operationId": "post_v1_desktop_mouse_scroll", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMouseScrollRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop mouse position after scroll", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "400": { + "description": "Invalid mouse scroll request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/mouse/up": { + "post": { + "tags": ["v1"], + "summary": "Release a desktop mouse button.", + "description": "Performs a health-gated optional pointer move followed by `xdotool mouseup`\nand returns the resulting mouse position.", + "operationId": "post_v1_desktop_mouse_up", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMouseUpRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop mouse position after button release", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopMousePositionResponse" + } + } + } + }, + "400": { + "description": "Invalid mouse up request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or input failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/open": { + "post": { + "tags": ["v1"], + "summary": "Open a file or URL with the default handler.", + "description": "Opens a file path or URL using xdg-open on the managed desktop.", + "operationId": "post_v1_desktop_open", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopOpenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Target opened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopOpenResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/recording/start": { + "post": { + "tags": ["v1"], + "summary": "Start desktop recording.", + "description": "Starts an ffmpeg x11grab recording against the managed desktop and returns\nthe created recording metadata.", + "operationId": "post_v1_desktop_recording_start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopRecordingStartRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop recording started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopRecordingInfo" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready or a recording is already active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop recording failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/recording/stop": { + "post": { + "tags": ["v1"], + "summary": "Stop desktop recording.", + "description": "Stops the active desktop recording and returns the finalized recording\nmetadata.", + "operationId": "post_v1_desktop_recording_stop", + "responses": { + "200": { + "description": "Desktop recording stopped", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopRecordingInfo" + } + } + } + }, + "409": { + "description": "No active desktop recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop recording stop failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/recordings": { + "get": { + "tags": ["v1"], + "summary": "List desktop recordings.", + "description": "Returns the current desktop recording catalog.", + "operationId": "get_v1_desktop_recordings", + "responses": { + "200": { + "description": "Desktop recordings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopRecordingListResponse" + } + } + } + }, + "502": { + "description": "Desktop recordings query failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/recordings/{id}": { + "get": { + "tags": ["v1"], + "summary": "Get desktop recording metadata.", + "description": "Returns metadata for a single desktop recording.", + "operationId": "get_v1_desktop_recording", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Desktop recording ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Desktop recording metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopRecordingInfo" + } + } + } + }, + "404": { + "description": "Unknown desktop recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": ["v1"], + "summary": "Delete a desktop recording.", + "description": "Removes a completed desktop recording and its file from disk.", + "operationId": "delete_v1_desktop_recording", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Desktop recording ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Desktop recording deleted" + }, + "404": { + "description": "Unknown desktop recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop recording is still active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/recordings/{id}/download": { + "get": { + "tags": ["v1"], + "summary": "Download a desktop recording.", + "description": "Serves the recorded MP4 bytes for a completed desktop recording.", + "operationId": "get_v1_desktop_recording_download", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Desktop recording ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Desktop recording as MP4 bytes" + }, + "404": { + "description": "Unknown desktop recording", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/screenshot": { + "get": { + "tags": ["v1"], + "summary": "Capture a full desktop screenshot.", + "description": "Performs a health-gated full-frame screenshot of the managed desktop and\nreturns the requested image bytes.", + "operationId": "get_v1_desktop_screenshot", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopScreenshotFormat" + } + ], + "nullable": true + } + }, + { + "name": "quality", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + } + }, + { + "name": "scale", + "in": "query", + "required": false, + "schema": { + "type": "number", + "format": "float", + "nullable": true + } + }, + { + "name": "showCursor", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Desktop screenshot as image bytes" + }, + "400": { + "description": "Invalid screenshot query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or screenshot capture failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/screenshot/region": { + "get": { + "tags": ["v1"], + "summary": "Capture a desktop screenshot region.", + "description": "Performs a health-gated screenshot crop against the managed desktop and\nreturns the requested region image bytes.", + "operationId": "get_v1_desktop_screenshot_region", + "parameters": [ + { + "name": "x", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "y", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "width", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "height", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopScreenshotFormat" + } + ], + "nullable": true + } + }, + { + "name": "quality", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + } + }, + { + "name": "scale", + "in": "query", + "required": false, + "schema": { + "type": "number", + "format": "float", + "nullable": true + } + }, + { + "name": "showCursor", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Desktop screenshot region as image bytes" + }, + "400": { + "description": "Invalid screenshot region", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop runtime health or screenshot capture failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/start": { + "post": { + "tags": ["v1"], + "summary": "Start the private desktop runtime.", + "description": "Lazily launches the managed Xvfb/openbox stack, validates display health,\nand returns the resulting desktop status snapshot.", + "operationId": "post_v1_desktop_start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStartRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Desktop runtime status after start", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid desktop start request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is already transitioning", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "501": { + "description": "Desktop API unsupported on this platform", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Desktop runtime could not be started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/status": { + "get": { + "tags": ["v1"], + "summary": "Get desktop runtime status.", + "description": "Returns the current desktop runtime state, dependency status, active\ndisplay metadata, and supervised process information.", + "operationId": "get_v1_desktop_status", + "responses": { + "200": { + "description": "Desktop runtime status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStatusResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/stop": { + "post": { + "tags": ["v1"], + "summary": "Stop the private desktop runtime.", + "description": "Terminates the managed openbox/Xvfb/dbus processes owned by the desktop\nruntime and returns the resulting status snapshot.", + "operationId": "post_v1_desktop_stop", + "responses": { + "200": { + "description": "Desktop runtime status after stop", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStatusResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is already transitioning", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/stream/signaling": { + "get": { + "tags": ["v1"], + "summary": "Open a desktop WebRTC signaling session.", + "description": "Upgrades the connection to a WebSocket used for WebRTC signaling between\nthe browser client and the desktop streaming process. Also accepts mouse\nand keyboard input frames as a fallback transport.", + "operationId": "get_v1_desktop_stream_ws", + "parameters": [ + { + "name": "access_token", + "in": "query", + "description": "Bearer token alternative for WS auth", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "101": { + "description": "WebSocket upgraded" + }, + "409": { + "description": "Desktop runtime or streaming session is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Desktop stream failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/stream/start": { + "post": { + "tags": ["v1"], + "summary": "Start desktop streaming.", + "description": "Enables desktop websocket streaming for the managed desktop.", + "operationId": "post_v1_desktop_stream_start", + "responses": { + "200": { + "description": "Desktop streaming started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStreamStatusResponse" + } + } + } + } + } + } + }, + "/v1/desktop/stream/status": { + "get": { + "tags": ["v1"], + "summary": "Get desktop stream status.", + "description": "Returns the current state of the desktop WebRTC streaming session.", + "operationId": "get_v1_desktop_stream_status", + "responses": { + "200": { + "description": "Desktop stream status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStreamStatusResponse" + } + } + } + } + } + } + }, + "/v1/desktop/stream/stop": { + "post": { + "tags": ["v1"], + "summary": "Stop desktop streaming.", + "description": "Disables desktop websocket streaming for the managed desktop.", + "operationId": "post_v1_desktop_stream_stop", + "responses": { + "200": { + "description": "Desktop streaming stopped", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStreamStatusResponse" + } + } + } + } + } + } + }, + "/v1/desktop/windows": { + "get": { + "tags": ["v1"], + "summary": "List visible desktop windows.", + "description": "Performs a health-gated visible-window enumeration against the managed\ndesktop and returns the current window metadata.", + "operationId": "get_v1_desktop_windows", + "responses": { + "200": { + "description": "Visible desktop windows", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowListResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Desktop runtime health or window query failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/focused": { + "get": { + "tags": ["v1"], + "summary": "Get the currently focused desktop window.", + "description": "Returns information about the window that currently has input focus.", + "operationId": "get_v1_desktop_windows_focused", + "responses": { + "200": { + "description": "Focused window info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "No window is focused", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/{id}/focus": { + "post": { + "tags": ["v1"], + "summary": "Focus a desktop window.", + "description": "Brings the specified window to the foreground and gives it input focus.", + "operationId": "post_v1_desktop_window_focus", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "X11 window ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Window info after focus", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "Window not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/{id}/move": { + "post": { + "tags": ["v1"], + "summary": "Move a desktop window.", + "description": "Moves the specified window to the given position.", + "operationId": "post_v1_desktop_window_move", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "X11 window ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Window info after move", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "Window not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/{id}/resize": { + "post": { + "tags": ["v1"], + "summary": "Resize a desktop window.", + "description": "Resizes the specified window to the given dimensions.", + "operationId": "post_v1_desktop_window_resize", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "X11 window ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowResizeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Window info after resize", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "Window not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/fs/entries": { "get": { "tags": ["v1"], @@ -911,6 +2719,21 @@ "summary": "List all managed processes.", "description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.", "operationId": "get_v1_processes", + "parameters": [ + { + "name": "owner", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProcessOwner" + } + ], + "nullable": true + } + } + ], "responses": { "200": { "description": "List processes", @@ -1934,6 +3757,769 @@ } } }, + "DesktopActionResponse": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { + "type": "boolean" + } + } + }, + "DesktopClipboardQuery": { + "type": "object", + "properties": { + "selection": { + "type": "string", + "nullable": true + } + } + }, + "DesktopClipboardResponse": { + "type": "object", + "required": ["text", "selection"], + "properties": { + "selection": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "DesktopClipboardWriteRequest": { + "type": "object", + "required": ["text"], + "properties": { + "selection": { + "type": "string", + "nullable": true + }, + "text": { + "type": "string" + } + } + }, + "DesktopDisplayInfoResponse": { + "type": "object", + "required": ["display", "resolution"], + "properties": { + "display": { + "type": "string" + }, + "resolution": { + "$ref": "#/components/schemas/DesktopResolution" + } + } + }, + "DesktopErrorInfo": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "DesktopKeyModifiers": { + "type": "object", + "properties": { + "alt": { + "type": "boolean", + "nullable": true + }, + "cmd": { + "type": "boolean", + "nullable": true + }, + "ctrl": { + "type": "boolean", + "nullable": true + }, + "shift": { + "type": "boolean", + "nullable": true + } + } + }, + "DesktopKeyboardDownRequest": { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "type": "string" + } + } + }, + "DesktopKeyboardPressRequest": { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "type": "string" + }, + "modifiers": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopKeyModifiers" + } + ], + "nullable": true + } + } + }, + "DesktopKeyboardTypeRequest": { + "type": "object", + "required": ["text"], + "properties": { + "delayMs": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "text": { + "type": "string" + } + } + }, + "DesktopKeyboardUpRequest": { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "type": "string" + } + } + }, + "DesktopLaunchRequest": { + "type": "object", + "required": ["app"], + "properties": { + "app": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "wait": { + "type": "boolean", + "nullable": true + } + } + }, + "DesktopLaunchResponse": { + "type": "object", + "required": ["processId"], + "properties": { + "pid": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "processId": { + "type": "string" + }, + "windowId": { + "type": "string", + "nullable": true + } + } + }, + "DesktopMouseButton": { + "type": "string", + "enum": ["left", "middle", "right"] + }, + "DesktopMouseClickRequest": { + "type": "object", + "required": ["x", "y"], + "properties": { + "button": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopMouseButton" + } + ], + "nullable": true + }, + "clickCount": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopMouseDownRequest": { + "type": "object", + "properties": { + "button": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopMouseButton" + } + ], + "nullable": true + }, + "x": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "y": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, + "DesktopMouseDragRequest": { + "type": "object", + "required": ["startX", "startY", "endX", "endY"], + "properties": { + "button": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopMouseButton" + } + ], + "nullable": true + }, + "endX": { + "type": "integer", + "format": "int32" + }, + "endY": { + "type": "integer", + "format": "int32" + }, + "startX": { + "type": "integer", + "format": "int32" + }, + "startY": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopMouseMoveRequest": { + "type": "object", + "required": ["x", "y"], + "properties": { + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopMousePositionResponse": { + "type": "object", + "required": ["x", "y"], + "properties": { + "screen": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "window": { + "type": "string", + "nullable": true + }, + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopMouseScrollRequest": { + "type": "object", + "required": ["x", "y"], + "properties": { + "deltaX": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "deltaY": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopMouseUpRequest": { + "type": "object", + "properties": { + "button": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopMouseButton" + } + ], + "nullable": true + }, + "x": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "y": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, + "DesktopOpenRequest": { + "type": "object", + "required": ["target"], + "properties": { + "target": { + "type": "string" + } + } + }, + "DesktopOpenResponse": { + "type": "object", + "required": ["processId"], + "properties": { + "pid": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "processId": { + "type": "string" + } + } + }, + "DesktopProcessInfo": { + "type": "object", + "required": ["name", "running"], + "properties": { + "logPath": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "pid": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "running": { + "type": "boolean" + } + } + }, + "DesktopRecordingInfo": { + "type": "object", + "required": ["id", "status", "fileName", "bytes", "startedAt"], + "properties": { + "bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "endedAt": { + "type": "string", + "nullable": true + }, + "fileName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "processId": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/DesktopRecordingStatus" + } + } + }, + "DesktopRecordingListResponse": { + "type": "object", + "required": ["recordings"], + "properties": { + "recordings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopRecordingInfo" + } + } + } + }, + "DesktopRecordingStartRequest": { + "type": "object", + "properties": { + "fps": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + } + } + }, + "DesktopRecordingStatus": { + "type": "string", + "enum": ["recording", "completed", "failed"] + }, + "DesktopRegionScreenshotQuery": { + "type": "object", + "required": ["x", "y", "width", "height"], + "properties": { + "format": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopScreenshotFormat" + } + ], + "nullable": true + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "quality": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "scale": { + "type": "number", + "format": "float", + "nullable": true + }, + "showCursor": { + "type": "boolean", + "nullable": true + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopResolution": { + "type": "object", + "required": ["width", "height"], + "properties": { + "dpi": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "DesktopScreenshotFormat": { + "type": "string", + "enum": ["png", "jpeg", "webp"] + }, + "DesktopScreenshotQuery": { + "type": "object", + "properties": { + "format": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopScreenshotFormat" + } + ], + "nullable": true + }, + "quality": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "scale": { + "type": "number", + "format": "float", + "nullable": true + }, + "showCursor": { + "type": "boolean", + "nullable": true + } + } + }, + "DesktopStartRequest": { + "type": "object", + "properties": { + "displayNum": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "dpi": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "recordingFps": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "stateDir": { + "type": "string", + "nullable": true + }, + "streamAudioCodec": { + "type": "string", + "nullable": true + }, + "streamFrameRate": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "streamVideoCodec": { + "type": "string", + "nullable": true + }, + "webrtcPortRange": { + "type": "string", + "nullable": true + }, + "width": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + } + } + }, + "DesktopState": { + "type": "string", + "enum": ["inactive", "install_required", "starting", "active", "stopping", "failed"] + }, + "DesktopStatusResponse": { + "type": "object", + "required": ["state"], + "properties": { + "display": { + "type": "string", + "nullable": true + }, + "installCommand": { + "type": "string", + "nullable": true + }, + "lastError": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopErrorInfo" + } + ], + "nullable": true + }, + "missingDependencies": { + "type": "array", + "items": { + "type": "string" + } + }, + "processes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopProcessInfo" + } + }, + "resolution": { + "allOf": [ + { + "$ref": "#/components/schemas/DesktopResolution" + } + ], + "nullable": true + }, + "runtimeLogPath": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "state": { + "$ref": "#/components/schemas/DesktopState" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopWindowInfo" + }, + "description": "Current visible windows (included when the desktop is active)." + } + } + }, + "DesktopStreamStatusResponse": { + "type": "object", + "required": ["active"], + "properties": { + "active": { + "type": "boolean" + }, + "processId": { + "type": "string", + "nullable": true + }, + "windowId": { + "type": "string", + "nullable": true + } + } + }, + "DesktopWindowInfo": { + "type": "object", + "required": ["id", "title", "x", "y", "width", "height", "isActive"], + "properties": { + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopWindowListResponse": { + "type": "object", + "required": ["windows"], + "properties": { + "windows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "DesktopWindowMoveRequest": { + "type": "object", + "required": ["x", "y"], + "properties": { + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopWindowResizeRequest": { + "type": "object", + "required": ["width", "height"], + "properties": { + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "ErrorType": { "type": "string", "enum": [ @@ -2326,7 +4912,7 @@ }, "ProcessInfo": { "type": "object", - "required": ["id", "command", "args", "tty", "interactive", "status", "createdAtMs"], + "required": ["id", "command", "args", "tty", "interactive", "owner", "status", "createdAtMs"], "properties": { "args": { "type": "array", @@ -2361,6 +4947,9 @@ "interactive": { "type": "boolean" }, + "owner": { + "$ref": "#/components/schemas/ProcessOwner" + }, "pid": { "type": "integer", "format": "int32", @@ -2398,6 +4987,19 @@ } } }, + "ProcessListQuery": { + "type": "object", + "properties": { + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/ProcessOwner" + } + ], + "nullable": true + } + } + }, "ProcessListResponse": { "type": "object", "required": ["processes"], @@ -2484,6 +5086,10 @@ "type": "string", "enum": ["stdout", "stderr", "combined", "pty"] }, + "ProcessOwner": { + "type": "string", + "enum": ["user", "desktop", "system"] + }, "ProcessRunRequest": { "type": "object", "required": ["command"], diff --git a/docs/pi-support-plan.md b/docs/pi-support-plan.md deleted file mode 100644 index 5e207a5..0000000 --- a/docs/pi-support-plan.md +++ /dev/null @@ -1,210 +0,0 @@ -# Pi Agent Support Plan (pi-mono) - -## Implementation Status Update - -- Runtime selection now supports two internal modes: - - `PerSession` (default for unknown/non-allowlisted Pi capabilities) - - `Shared` (allowlist-only compatibility path) -- Pi sessions now use per-session process isolation by default, enabling true concurrent Pi sessions in Inspector and API clients. -- Shared Pi server code remains available and is used only when capability checks allow multiplexing. -- Session termination for per-session Pi mode hard-kills the underlying Pi process and clears queued prompts/pending waiters. -- In-session concurrent sends are serialized with an unbounded daemon-side FIFO queue per session. - -## Investigation Summary - -### Pi CLI modes and RPC protocol -- Pi supports multiple modes including interactive, print/JSON output, RPC, and SDK usage. JSON mode outputs a stream of JSON events suitable for parsing, and RPC mode is intended for programmatic control over stdin/stdout. -- RPC mode is started with `pi --mode rpc` and supports options like `--provider`, `--model`, `--no-session`, and `--session-dir`. -- The RPC protocol is newline-delimited JSON over stdin/stdout: - - Commands are JSON objects written to stdin. - - Responses are JSON objects with `type: "response"` and optional `id`. - - Events are JSON objects without `id`. -- `prompt` can include images using `ImageContent` (base64 or URL) alongside text. -- JSON/print mode (`pi -p` or `pi --print --mode json`) produces JSONL for non-interactive parsing and can resume sessions with a token. - -### RPC commands -RPC commands listed in `rpc.md` include: -- `new_session`, `get_state`, `list_sessions`, `delete_session`, `rename_session`, `clear_session` -- `prompt`, `queue_message`, `abort`, `get_queued_messages` - -### RPC event types -RPC events listed in `rpc.md` include: -- `agent_start`, `agent_end` -- `turn_start`, `turn_end` -- `message_start`, `message_update`, `message_end` -- `tool_execution_start`, `tool_execution_update`, `tool_execution_end` -- `auto_compaction`, `auto_retry`, `hook_error` - -`message_update` uses `assistantMessageEvent` deltas such as: -- `start`, `text_start`, `text_delta`, `text_end` -- `thinking_start`, `thinking_delta`, `thinking_end` -- `toolcall_start`, `toolcall_delta`, `toolcall_end` -- `toolcall_args_start`, `toolcall_args_delta`, `toolcall_args_end` -- `done`, `error` - -`tool_execution_update` includes `partialResult`, which is described as accumulated output so far. - -### Schema source locations (pi-mono) -RPC types are documented as living in: -- `packages/ai/src/types.ts` (Model types) -- `packages/agent/src/types.ts` (AgentResponse types) -- `packages/coding-agent/src/core/messages.ts` (message types) -- `packages/coding-agent/src/modes/rpc/rpc-types.ts` (RPC protocol types) - -### Distribution assets -Pi releases provide platform-specific binaries such as: -- `pi-darwin-arm64`, `pi-darwin-x64` -- `pi-linux-arm64`, `pi-linux-x64` -- `pi-win-x64.zip` - -## Integration Decisions -- Follow the OpenCode pattern: a shared long-running process (stdio RPC) with session multiplexing. -- Primary integration path is RPC streaming (`pi --mode rpc`). -- JSON/print mode is a fallback only (diagnostics or non-interactive runs). -- Create sessions via `new_session`; store the returned `sessionId` as `native_session_id`. -- Use `get_state` as a re-sync path after server restarts. -- Use `prompt` for send-message, with optional image content. -- Convert Pi events into universal events; emit daemon synthetic `session.started` on session creation and `session.ended` only on errors/termination. - -## Implementation Plan - -### 1) Agent Identity + Capabilities -Files: -- `server/packages/agent-management/src/agents.rs` -- `server/packages/sandbox-agent/src/router.rs` -- `docs/cli.mdx`, `docs/conversion.mdx`, `docs/session-transcript-schema.mdx` -- `README.md`, `frontend/packages/website/src/components/FAQ.tsx` - -Tasks: -- Add `AgentId::Pi` with string/binary name `"pi"` and parsing rules. -- Add Pi to `all_agents()` and agent lists. -- Define `AgentCapabilities` for Pi: - - `tool_calls=true`, `tool_results=true` - - `text_messages=true`, `streaming_deltas=true`, `item_started=true` - - `reasoning=true` (from `thinking_*` deltas) - - `images=true` (ImageContent in `prompt`) - - `permissions=false`, `questions=false`, `mcp_tools=false` - - `shared_process=true`, `session_lifecycle=false` (no native session events) - - `error_events=true` (hook_error) - - `command_execution=false`, `file_changes=false`, `file_attachments=false` - -### 2) Installer and Binary Resolution -Files: -- `server/packages/agent-management/src/agents.rs` - -Tasks: -- Add `install_pi()` that: - - Downloads the correct release asset per platform (`pi-`). - - Handles `.zip` on Windows and raw binaries elsewhere. - - Marks binary executable. -- Add Pi to `AgentManager::install`, `is_installed`, `version`. -- Version detection: try `--version`, `version`, `-V`. - -### 3) Schema Extraction for Pi -Files: -- `resources/agent-schemas/src/pi.ts` (new) -- `resources/agent-schemas/src/index.ts` -- `resources/agent-schemas/artifacts/json-schema/pi.json` -- `server/packages/extracted-agent-schemas/build.rs` -- `server/packages/extracted-agent-schemas/src/lib.rs` - -Tasks: -- Implement `extractPiSchema()`: - - Download pi-mono sources (zip/tarball) into a temp dir. - - Use `ts-json-schema-generator` against `packages/coding-agent/src/modes/rpc/rpc-types.ts`. - - Include dependent files per `rpc.md` (ai/types, agent/types, core/messages). - - Extract `RpcEvent`, `RpcResponse`, `RpcCommand` unions (exact type names from source). -- Add fallback schema if remote fetch fails (minimal union with event/response fields). -- Wire pi into extractor index and artifact generation. - -### 4) Universal Schema Conversion (Pi -> Universal) -Files: -- `server/packages/universal-agent-schema/src/agents/pi.rs` (new) -- `server/packages/universal-agent-schema/src/agents/mod.rs` -- `server/packages/universal-agent-schema/src/lib.rs` -- `server/packages/sandbox-agent/src/router.rs` - -Mapping rules: -- `message_start` -> `item.started` (kind=message, role=assistant, native_item_id=messageId) -- `message_update`: - - `text_*` -> `item.delta` (assistant text delta) - - `thinking_*` -> `item.delta` with `ContentPart::Reasoning` (visibility=Private) - - `toolcall_*` and `toolcall_args_*` -> ignore for now (tool_execution_* is authoritative) - - `error` -> `item.completed` with `ItemStatus::Failed` (if no later message_end) -- `message_end` -> `item.completed` (finalize assistant message) -- `tool_execution_start` -> `item.started` (kind=tool_call, ContentPart::ToolCall) -- `tool_execution_update` -> `item.delta` for a synthetic tool_result item: - - Maintain a per-toolCallId buffer to compute delta from accumulated `partialResult`. -- `tool_execution_end` -> `item.completed` (kind=tool_result, output from `result.content`) - - If `isError=true`, set item status to failed. -- `agent_start`, `turn_start`, `turn_end`, `agent_end`, `auto_compaction`, `auto_retry`, `hook_error`: - - Map to `ItemKind::Status` with a label like `pi.agent_start`, `pi.auto_retry`, etc. - - Do not emit `session.ended` for these events. -- If event parsing fails, emit `agent.unparsed` (source=daemon, synthetic=true) and fail tests. - -### 5) Shared RPC Server Integration -Files: -- `server/packages/sandbox-agent/src/router.rs` - -Tasks: -- Add a new managed stdio server type for Pi, similar to Codex: - - Create `PiServer` struct with: - - stdin sender - - pending request map keyed by request id - - per-session native session id mapping - - Extend `ManagedServerKind` to include Pi. - - Add `ensure_pi_server()` and `spawn_pi_server()` using `pi --mode rpc`. - - Add a `handle_pi_server_output()` loop to parse stdout lines into events/responses. -- Session creation: - - On `create_session`, ensure Pi server is running, send `new_session`, store sessionId. - - Register session with `server_manager.register_session` for native mapping. -- Sending messages: - - Use `prompt` command; include sessionId and optional images. - - Emit synthetic `item.started` only if Pi does not emit `message_start`. - -### 6) Router + Streaming Path Changes -Files: -- `server/packages/sandbox-agent/src/router.rs` - -Tasks: -- Add Pi handling to: - - `create_session` (new_session) - - `send_message` (prompt) - - `parse_agent_line` (Pi event conversion) - - `agent_modes` (default to `default` unless Pi exposes a mode list) - - `agent_supports_resume` (true if Pi supports session resume) - -### 7) Tests -Files: -- `server/packages/sandbox-agent/tests/...` -- `server/packages/universal-agent-schema/tests/...` (if present) - -Tasks: -- Unit tests for conversion: - - `message_start/update/end` -> item.started/delta/completed - - `tool_execution_*` -> tool call/result mapping with partialResult delta - - failure -> agent.unparsed -- Integration tests: - - Start Pi RPC server, create session, send prompt, stream events. - - Validate `native_session_id` mapping and event ordering. -- Update HTTP/SSE test coverage to include Pi agent if relevant. - -## Risk Areas / Edge Cases -- `tool_execution_update.partialResult` is cumulative; must compute deltas. -- `message_update` may emit `done`/`error` without `message_end`; handle both paths. -- No native session lifecycle events; rely on daemon synthetic events. -- Session recovery after RPC server restart requires `get_state` + re-register sessions. - -## Acceptance Criteria -- Pi appears in `/v1/agents`, CLI list, and docs. -- `create_session` returns `native_session_id` from Pi `new_session`. -- Streaming prompt yields universal events with proper ordering: - - message -> item.started/delta/completed - - tool execution -> tool call + tool result -- Tests pass and no synthetic data is used in test fixtures. - -## Sources -- https://upd.dev/badlogic/pi-mono/src/commit/d36e0ea07303d8a76d51b4a7bd5f0d6d3c490860/packages/coding-agent/docs/rpc.md -- https://buildwithpi.ai/pi-cli -- https://takopi.dev/docs/pi-cli/ -- https://upd.dev/badlogic/pi-mono/releases diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 33f7120..223a54d 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -1,370 +1,289 @@ --- title: "Quickstart" -description: "Get a coding agent running in a sandbox in under a minute." +description: "Start the server and send your first message." icon: "rocket" --- - + - + ```bash - npm install sandbox-agent@0.3.x + npx skills add rivet-dev/skills -s sandbox-agent ``` - + ```bash - bun add sandbox-agent@0.3.x - # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). - bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 + bunx skills add rivet-dev/skills -s sandbox-agent ``` - - `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. + + Each coding agent requires API keys to connect to their respective LLM providers. - + ```bash - npm install sandbox-agent@0.3.x + export ANTHROPIC_API_KEY="sk-ant-..." + export OPENAI_API_KEY="sk-..." ``` - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { local } from "sandbox-agent/local"; - - // Runs on your machine. Inherits process.env automatically. - const client = await SandboxAgent.start({ - sandbox: local(), - }); - ``` - - See [Local deploy guide](/deploy/local) - ```bash - npm install sandbox-agent@0.3.x @e2b/code-interpreter - ``` - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { e2b } from "sandbox-agent/e2b"; + import { Sandbox } from "@e2b/code-interpreter"; - // Provisions a cloud sandbox on E2B, installs the server, and connects. - const client = await SandboxAgent.start({ - sandbox: e2b(), - }); + const envs: Record = {}; + if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + + const sandbox = await Sandbox.create({ envs }); ``` - - See [E2B deploy guide](/deploy/e2b) - ```bash - npm install sandbox-agent@0.3.x @daytonaio/sdk - ``` - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { daytona } from "sandbox-agent/daytona"; + import { Daytona } from "@daytonaio/sdk"; - // Provisions a Daytona workspace with the server pre-installed. - const client = await SandboxAgent.start({ - sandbox: daytona(), + const envVars: Record = {}; + if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + + const daytona = new Daytona(); + const sandbox = await daytona.create({ + snapshot: "sandbox-agent-ready", + envVars, }); ``` - - See [Daytona deploy guide](/deploy/daytona) - - - - ```bash - npm install sandbox-agent@0.3.x @vercel/sandbox - ``` - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { vercel } from "sandbox-agent/vercel"; - - // Provisions a Vercel sandbox with the server installed on boot. - const client = await SandboxAgent.start({ - sandbox: vercel(), - }); - ``` - - See [Vercel deploy guide](/deploy/vercel) - - - - ```bash - npm install sandbox-agent@0.3.x modal - ``` - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { modal } from "sandbox-agent/modal"; - - // Builds a container image with agents pre-installed (cached after first run), - // starts a Modal sandbox from that image, and connects. - const client = await SandboxAgent.start({ - sandbox: modal(), - }); - ``` - - See [Modal deploy guide](/deploy/modal) - - - - ```bash - npm install sandbox-agent@0.3.x @cloudflare/sandbox - ``` - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { cloudflare } from "sandbox-agent/cloudflare"; - import { SandboxClient } from "@cloudflare/sandbox"; - - // Uses the Cloudflare Sandbox SDK to provision and connect. - // The Cloudflare SDK handles server lifecycle internally. - const cfSandboxClient = new SandboxClient(); - const client = await SandboxAgent.start({ - sandbox: cloudflare({ sdk: cfSandboxClient }), - }); - ``` - - See [Cloudflare deploy guide](/deploy/cloudflare) ```bash - npm install sandbox-agent@0.3.x dockerode get-port + docker run -p 2468:2468 \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ + -e OPENAI_API_KEY="sk-..." \ + rivetdev/sandbox-agent:0.4.2-full \ + server --no-token --host 0.0.0.0 --port 2468 ``` - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - import { docker } from "sandbox-agent/docker"; - - // Runs a Docker container locally. Good for testing. - const client = await SandboxAgent.start({ - sandbox: docker(), - }); - ``` - - See [Docker deploy guide](/deploy/docker) -
- - **More info:** - - - Agents need API keys for their LLM provider. Each provider passes credentials differently: - - ```typescript - // Local — inherits process.env automatically - - // E2B - e2b({ create: { envs: { ANTHROPIC_API_KEY: "..." } } }) - - // Daytona - daytona({ create: { envVars: { ANTHROPIC_API_KEY: "..." } } }) - - // Vercel - vercel({ create: { env: { ANTHROPIC_API_KEY: "..." } } }) - - // Modal - modal({ create: { secrets: { ANTHROPIC_API_KEY: "..." } } }) - - // Docker - docker({ env: ["ANTHROPIC_API_KEY=..."] }) - ``` - - For multi-tenant billing, per-user keys, and gateway options, see [LLM Credentials](/llm-credentials). + + Use `sandbox-agent credentials extract-env --export` to extract your existing API keys (Anthropic, OpenAI, etc.) from local Claude Code or Codex config files. - - - Implement the `SandboxProvider` interface to use any sandbox platform: - - ```typescript - import { SandboxAgent, type SandboxProvider } from "sandbox-agent"; - - const myProvider: SandboxProvider = { - name: "my-provider", - async create() { - // Provision a sandbox, install & start the server, return an ID - return "sandbox-123"; - }, - async destroy(sandboxId) { - // Tear down the sandbox - }, - async getUrl(sandboxId) { - // Return the Sandbox Agent server URL - return `https://${sandboxId}.my-platform.dev:3000`; - }, - }; - - const client = await SandboxAgent.start({ - sandbox: myProvider, - }); - ``` + + Use the `mock` agent for SDK and integration testing without provider credentials. - - - If you already have a Sandbox Agent server running, connect directly: - - ```typescript - const client = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - }); - ``` - - - - - - ```bash - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 - ``` - - - ```bash - npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 - ``` - - - ```bash - docker run -p 2468:2468 \ - -e ANTHROPIC_API_KEY="sk-ant-..." \ - -e OPENAI_API_KEY="sk-..." \ - rivetdev/sandbox-agent:0.4.1-rc.1-full \ - server --no-token --host 0.0.0.0 --port 2468 - ``` - - + + For per-tenant token tracking, budget enforcement, or usage-based billing, see [LLM Credentials](/llm-credentials) for gateway options like OpenRouter, LiteLLM, and Portkey. - - + + + + Install and run the binary directly. - ```typescript Claude - const session = await client.createSession({ - agent: "claude", - }); + ```bash + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh + sandbox-agent server --no-token --host 0.0.0.0 --port 2468 + ``` + - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + + Run without installing globally. - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + ```bash + npx @sandbox-agent/cli@0.4.x server --no-token --host 0.0.0.0 --port 2468 + ``` + - console.log(result.stopReason); - ``` + + Run without installing globally. - ```typescript Codex - const session = await client.createSession({ - agent: "codex", - }); + ```bash + bunx @sandbox-agent/cli@0.4.x server --no-token --host 0.0.0.0 --port 2468 + ``` + - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + + Install globally, then run. - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + ```bash + npm install -g @sandbox-agent/cli@0.4.x + sandbox-agent server --no-token --host 0.0.0.0 --port 2468 + ``` + - console.log(result.stopReason); - ``` + + Install globally, then run. - ```typescript OpenCode - const session = await client.createSession({ - agent: "opencode", - }); + ```bash + bun add -g @sandbox-agent/cli@0.4.x + # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). + bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 + sandbox-agent server --no-token --host 0.0.0.0 --port 2468 + ``` + - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + + For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + ```bash + npm install sandbox-agent@0.4.x + ``` - console.log(result.stopReason); - ``` + ```typescript + import { SandboxAgent } from "sandbox-agent"; - ```typescript Cursor - const session = await client.createSession({ - agent: "cursor", - }); + const sdk = await SandboxAgent.start(); + ``` + - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + + For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + ```bash + bun add sandbox-agent@0.4.x + # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). + bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 + ``` - console.log(result.stopReason); - ``` + ```typescript + import { SandboxAgent } from "sandbox-agent"; - ```typescript Amp - const session = await client.createSession({ - agent: "amp", - }); + const sdk = await SandboxAgent.start(); + ``` + - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + + If you're running from source instead of the installed CLI. - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + ```bash + cargo run -p sandbox-agent -- server --no-token --host 0.0.0.0 --port 2468 + ``` + + - console.log(result.stopReason); - ``` + Binding to `0.0.0.0` allows the server to accept connections from any network interface, which is required when running inside a sandbox where clients connect remotely. - ```typescript Pi - const session = await client.createSession({ - agent: "pi", - }); + + + Tokens are usually not required. Most sandbox providers (E2B, Daytona, etc.) already secure networking at the infrastructure layer. - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + If you expose the server publicly, use `--token "$SANDBOX_TOKEN"` to require authentication: - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + ```bash + sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468 + ``` - console.log(result.stopReason); - ``` + Then pass the token when connecting: - + + + ```typescript + import { SandboxAgent } from "sandbox-agent"; - See [Agent Sessions](/agent-sessions) for the full sessions API. + const sdk = await SandboxAgent.connect({ + baseUrl: "http://your-server:2468", + token: process.env.SANDBOX_TOKEN, + }); + ``` + + + + ```bash + curl "http://your-server:2468/v1/health" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" + ``` + + + + ```bash + sandbox-agent --token "$SANDBOX_TOKEN" api agents list \ + --endpoint http://your-server:2468 + ``` + + + + + If you're calling the server from a browser, see the [CORS configuration guide](/cors). + + - - ```typescript - await client.destroySandbox(); // provider-defined cleanup and disconnect + + To preinstall agents: + + ```bash + sandbox-agent install-agent --all ``` - Use `client.dispose()` instead to disconnect without changing sandbox state. On E2B, `client.pauseSandbox()` pauses the sandbox and `client.killSandbox()` deletes it permanently. + If agents are not installed up front, they are lazily installed when creating a session. - - Open the Inspector at `/ui/` on your server (e.g. `http://localhost:2468/ui/`) to view sessions and events in a GUI. + + If you want to use `/v1/desktop/*`, install the desktop runtime packages first: + + ```bash + sandbox-agent install desktop --yes + ``` + + Then use `GET /v1/desktop/status` or `sdk.getDesktopStatus()` to verify the runtime is ready before calling desktop screenshot or input APIs. + + + + ```typescript + import { SandboxAgent } from "sandbox-agent"; + + const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + }); + + const session = await sdk.createSession({ + agent: "claude", + sessionInit: { + cwd: "/", + mcpServers: [], + }, + }); + + console.log(session.id); + ``` + + + + ```typescript + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + + + ```typescript + const off = session.onEvent((event) => { + console.log(event.sender, event.payload); + }); + + const page = await sdk.getEvents({ + sessionId: session.id, + limit: 50, + }); + + console.log(page.items.length); + off(); + ``` + + + + Open the Inspector UI at `/ui/` on your server (for example, `http://localhost:2468/ui/`) to inspect sessions and events in a GUI. Sandbox Agent Inspector @@ -372,44 +291,16 @@ icon: "rocket" -## Full example - -```typescript -import { SandboxAgent } from "sandbox-agent"; -import { e2b } from "sandbox-agent/e2b"; - -const client = await SandboxAgent.start({ - sandbox: e2b({ - create: { - envs: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }, - }, - }), -}); - -try { - const session = await client.createSession({ agent: "claude" }); - - session.onEvent((event) => { - console.log(`[${event.sender}]`, JSON.stringify(event.payload)); - }); - - const result = await session.prompt([ - { type: "text", text: "Write a function that checks if a number is prime." }, - ]); - - console.log("Done:", result.stopReason); -} finally { - await client.destroySandbox(); -} -``` - ## Next steps - - - Full TypeScript SDK API surface. + + + Configure in-memory, Rivet Actor state, IndexedDB, SQLite, and Postgres persistence. - Deploy to E2B, Daytona, Docker, Vercel, or Cloudflare. + Deploy your agent to E2B, Daytona, Docker, Vercel, or Cloudflare. + + + Use the latest TypeScript SDK API. diff --git a/docs/react-components.mdx b/docs/react-components.mdx index 93183b2..71a76d2 100644 --- a/docs/react-components.mdx +++ b/docs/react-components.mdx @@ -17,7 +17,7 @@ Current exports: ## Install ```bash -npm install @sandbox-agent/react@0.3.x +npm install @sandbox-agent/react@0.4.x ``` ## Full example diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index 8e7c8f6..73e0d35 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -11,12 +11,12 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. ```bash - npm install sandbox-agent@0.3.x + npm install sandbox-agent@0.4.x ``` ```bash - bun add sandbox-agent@0.3.x + bun add sandbox-agent@0.4.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -26,7 +26,7 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. ## Optional React components ```bash -npm install @sandbox-agent/react@0.3.x +npm install @sandbox-agent/react@0.4.x ``` ## Create a client @@ -196,6 +196,44 @@ const writeResult = await sdk.writeFsFile({ path: "./hello.txt" }, "hello"); console.log(health.status, agents.agents.length, entries.length, writeResult.path); ``` +## Desktop API + +The SDK also wraps the desktop host/runtime HTTP API. + +Install desktop dependencies first on Linux hosts: + +```bash +sandbox-agent install desktop --yes +``` + +Then query status, surface remediation if needed, and start the runtime: + +```ts +const status = await sdk.getDesktopStatus(); + +if (status.state === "install_required") { + console.log(status.installCommand); +} + +const started = await sdk.startDesktop({ + width: 1440, + height: 900, + dpi: 96, +}); + +const screenshot = await sdk.takeDesktopScreenshot(); +const displayInfo = await sdk.getDesktopDisplayInfo(); + +await sdk.moveDesktopMouse({ x: 400, y: 300 }); +await sdk.clickDesktop({ x: 400, y: 300, button: "left", clickCount: 1 }); +await sdk.typeDesktopText({ text: "hello world", delayMs: 10 }); +await sdk.pressDesktopKey({ key: "ctrl+l" }); + +await sdk.stopDesktop(); +``` + +Screenshot helpers return `Uint8Array` PNG bytes. The SDK does not attempt to install OS packages remotely; callers should surface `missingDependencies` and `installCommand` from `getDesktopStatus()`. + ## Error handling ```ts diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx deleted file mode 100644 index c9c004a..0000000 --- a/docs/session-transcript-schema.mdx +++ /dev/null @@ -1,388 +0,0 @@ ---- -title: "Session Transcript Schema" -description: "Universal event schema for session transcripts across all agents." ---- - -Each coding agent outputs events in its own native format. The sandbox-agent converts these into a universal event schema, giving you a consistent session transcript regardless of which agent you use. - -The schema is defined in [OpenAPI format](https://github.com/rivet-dev/sandbox-agent/blob/main/docs/openapi.json). See the [HTTP API Reference](/api-reference) for endpoint documentation. - -## Coverage Matrix - -This table shows which agent feature coverage appears in the universal event stream. All agents retain their full native feature coverage—this only reflects what's normalized into the schema. - -| Feature | Claude | Codex | OpenCode | Amp | Pi (RPC) | -|--------------------|:------:|:-----:|:------------:|:------------:|:------------:| -| Stability | Stable | Stable| Experimental | Experimental | Experimental | -| Text Messages | ✓ | ✓ | ✓ | ✓ | ✓ | -| Tool Calls | ✓ | ✓ | ✓ | ✓ | ✓ | -| Tool Results | ✓ | ✓ | ✓ | ✓ | ✓ | -| Questions (HITL) | ✓ | | ✓ | | | -| Permissions (HITL) | ✓ | ✓ | ✓ | - | | -| Images | - | ✓ | ✓ | - | ✓ | -| File Attachments | - | ✓ | ✓ | - | | -| Session Lifecycle | - | ✓ | ✓ | - | | -| Error Events | - | ✓ | ✓ | ✓ | ✓ | -| Reasoning/Thinking | - | ✓ | - | - | ✓ | -| Command Execution | - | ✓ | - | - | | -| File Changes | - | ✓ | - | - | | -| MCP Tools | ✓ | ✓ | ✓ | ✓ | | -| Streaming Deltas | ✓ | ✓ | ✓ | - | ✓ | -| Variants | | ✓ | ✓ | ✓ | ✓ | - -Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) · [Codex](https://github.com/openai/codex) · [OpenCode](https://github.com/opencode-ai/opencode) · [Amp](https://ampcode.com) · [Pi](https://buildwithpi.ai/pi-cli) - -- ✓ = Appears in session events -- \- = Agent supports natively, schema conversion coming soon -- (blank) = Not supported by agent -- Pi runtime model is router-managed per-session RPC (`pi --mode rpc`); it does not use generic subprocess streaming. - - - - Basic message exchange between user and assistant. - - - Visibility into tool invocations (file reads, command execution, etc.) and their results. When not natively supported, tool activity is embedded in message content. - - - Interactive questions the agent asks the user. Emits `question.requested` and `question.resolved` events. - - - Permission requests for sensitive operations. Emits `permission.requested` and `permission.resolved` events. - - - Support for image attachments in messages. - - - Support for file attachments in messages. - - - Native `session.started` and `session.ended` events. When not supported, the daemon emits synthetic lifecycle events. - - - Structured error events for runtime failures. - - - Extended thinking or reasoning content with visibility controls. - - - Detailed command execution events with stdout/stderr. - - - Structured file modification events with diffs. - - - Model Context Protocol tool support. - - - Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`. - - - Model variants such as reasoning effort or depth. Agents may expose different variant sets per model. - - - -Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it. - -## UniversalEvent - -Every event from the API is wrapped in a `UniversalEvent` envelope. - -| Field | Type | Description | -|-------|------|-------------| -| `event_id` | string | Unique identifier for this event | -| `sequence` | integer | Monotonic sequence number within the session (starts at 1) | -| `time` | string | RFC3339 timestamp | -| `session_id` | string | Daemon-generated session identifier | -| `native_session_id` | string? | Provider-native session/thread identifier (e.g., Codex `threadId`, OpenCode `sessionID`) | -| `source` | string | Event origin: `agent` (native) or `daemon` (synthetic) | -| `synthetic` | boolean | Whether this event was generated by the daemon to fill gaps | -| `type` | string | Event type (see [Event Types](#event-types)) | -| `data` | object | Event-specific payload | -| `raw` | any? | Original provider payload (only when `include_raw=true`) | - -```json -{ - "event_id": "evt_abc123", - "sequence": 1, - "time": "2025-01-28T12:00:00Z", - "session_id": "my-session", - "native_session_id": "thread_xyz", - "source": "agent", - "synthetic": false, - "type": "item.completed", - "data": { ... } -} -``` - -## Event Types - -### Session Lifecycle - -| Type | Description | Data | -|------|-------------|------| -| `session.started` | Session has started | `{ metadata?: any }` | -| `session.ended` | Session has ended | `{ reason, terminated_by, message?, exit_code? }` | - -### Turn Lifecycle - -| Type | Description | Data | -|------|-------------|------| -| `turn.started` | Turn has started | `{ phase: "started", turn_id?, metadata? }` | -| `turn.ended` | Turn has ended | `{ phase: "ended", turn_id?, metadata? }` | - -**SessionEndedData** - -| Field | Type | Values | -|-------|------|--------| -| `reason` | string | `completed`, `error`, `terminated` | -| `terminated_by` | string | `agent`, `daemon` | -| `message` | string? | Error message (only present when reason is `error`) | -| `exit_code` | int? | Process exit code (only present when reason is `error`) | -| `stderr` | StderrOutput? | Structured stderr output (only present when reason is `error`) | - -**StderrOutput** - -| Field | Type | Description | -|-------|------|-------------| -| `head` | string? | First 20 lines of stderr (if truncated) or full stderr (if not truncated) | -| `tail` | string? | Last 50 lines of stderr (only present if truncated) | -| `truncated` | boolean | Whether the output was truncated | -| `total_lines` | int? | Total number of lines in stderr | - -### Item Lifecycle - -| Type | Description | Data | -|------|-------------|------| -| `item.started` | Item creation | `{ item }` | -| `item.delta` | Streaming content delta | `{ item_id, native_item_id?, delta }` | -| `item.completed` | Item finalized | `{ item }` | - -Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more) → `item.completed`. - -### HITL (Human-in-the-Loop) - -| Type | Description | Data | -|------|-------------|------| -| `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` | -| `permission.resolved` | Permission decision recorded | `{ permission_id, action, status, metadata? }` | -| `question.requested` | Question pending user input | `{ question_id, prompt, options, status }` | -| `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` | - -**PermissionEventData** - -| Field | Type | Description | -|-------|------|-------------| -| `permission_id` | string | Identifier for the permission request | -| `action` | string | What the agent wants to do | -| `status` | string | `requested`, `accept`, `accept_for_session`, `reject` | -| `metadata` | any? | Additional context | - -**QuestionEventData** - -| Field | Type | Description | -|-------|------|-------------| -| `question_id` | string | Identifier for the question | -| `prompt` | string | Question text | -| `options` | string[] | Available answer options | -| `status` | string | `requested`, `answered`, `rejected` | -| `response` | string? | Selected answer (when resolved) | - -### Errors - -| Type | Description | Data | -|------|-------------|------| -| `error` | Runtime error | `{ message, code?, details? }` | -| `agent.unparsed` | Parse failure | `{ error, location, raw_hash? }` | - -The `agent.unparsed` event indicates the daemon failed to parse an agent payload. This should be treated as a bug. - -## UniversalItem - -Items represent discrete units of content within a session. - -| Field | Type | Description | -|-------|------|-------------| -| `item_id` | string | Daemon-generated identifier | -| `native_item_id` | string? | Provider-native item/message identifier | -| `parent_id` | string? | Parent item ID (e.g., tool call/result parented to a message) | -| `kind` | string | Item category (see below) | -| `role` | string? | Actor role for message items | -| `status` | string | Lifecycle status | -| `content` | ContentPart[] | Ordered list of content parts | - -### ItemKind - -| Value | Description | -|-------|-------------| -| `message` | User or assistant message | -| `tool_call` | Tool invocation | -| `tool_result` | Tool execution result | -| `system` | System message | -| `status` | Status update | -| `unknown` | Unrecognized item type | - -### ItemRole - -| Value | Description | -|-------|-------------| -| `user` | User message | -| `assistant` | Assistant response | -| `system` | System prompt | -| `tool` | Tool-related message | - -### ItemStatus - -| Value | Description | -|-------|-------------| -| `in_progress` | Item is streaming or pending | -| `completed` | Item is finalized | -| `failed` | Item execution failed | - -## Content Parts - -The `content` array contains typed parts that make up an item's payload. - -### text - -Plain text content. - -```json -{ "type": "text", "text": "Hello, world!" } -``` - -### json - -Structured JSON content. - -```json -{ "type": "json", "json": { "key": "value" } } -``` - -### tool_call - -Tool invocation. - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Tool name | -| `arguments` | string | JSON-encoded arguments | -| `call_id` | string | Unique call identifier | - -```json -{ - "type": "tool_call", - "name": "read_file", - "arguments": "{\"path\": \"/src/main.ts\"}", - "call_id": "call_abc123" -} -``` - -### tool_result - -Tool execution result. - -| Field | Type | Description | -|-------|------|-------------| -| `call_id` | string | Matching call identifier | -| `output` | string | Tool output | - -```json -{ - "type": "tool_result", - "call_id": "call_abc123", - "output": "File contents here..." -} -``` - -### file_ref - -File reference with optional diff. - -| Field | Type | Description | -|-------|------|-------------| -| `path` | string | File path | -| `action` | string | `read`, `write`, `patch` | -| `diff` | string? | Unified diff (for patches) | - -```json -{ - "type": "file_ref", - "path": "/src/main.ts", - "action": "write", - "diff": "@@ -1,3 +1,4 @@\n+import { foo } from 'bar';" -} -``` - -### image - -Image reference. - -| Field | Type | Description | -|-------|------|-------------| -| `path` | string | Image file path | -| `mime` | string? | MIME type | - -```json -{ "type": "image", "path": "/tmp/screenshot.png", "mime": "image/png" } -``` - -### reasoning - -Model reasoning/thinking content. - -| Field | Type | Description | -|-------|------|-------------| -| `text` | string | Reasoning text | -| `visibility` | string | `public` or `private` | - -```json -{ "type": "reasoning", "text": "Let me think about this...", "visibility": "public" } -``` - -### status - -Status indicator. - -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Status label | -| `detail` | string? | Additional detail | - -```json -{ "type": "status", "label": "Running tests", "detail": "3 of 10 passed" } -``` - -## Source & Synthetics - -### EventSource - -The `source` field indicates who emitted the event: - -| Value | Description | -|-------|-------------| -| `agent` | Native event from the agent | -| `daemon` | Synthetic event generated by the daemon | - -### Synthetic Events - -The daemon emits synthetic events (`synthetic: true`, `source: "daemon"`) to provide a consistent event stream across all agents. Common synthetics: - -| Synthetic | When | -|-----------|------| -| `session.started` | Agent doesn't emit explicit session start | -| `session.ended` | Agent doesn't emit explicit session end | -| `turn.started` | Agent doesn't emit explicit turn start | -| `turn.ended` | Agent doesn't emit explicit turn end | -| `item.started` | Agent doesn't emit item start events | -| `item.delta` | Agent doesn't stream deltas natively | -| `question.*` | Claude Code plan mode (from ExitPlanMode tool) | - -### Raw Payloads - -Pass `include_raw=true` to event endpoints to receive the original agent payload in the `raw` field. Useful for debugging or accessing agent-specific data not in the universal schema. - -```typescript -const events = await client.getEvents("my-session", { includeRaw: true }); -// events[0].raw contains the original agent payload -``` diff --git a/docs/theme.css b/docs/theme.css index daeb719..4286d2c 100644 --- a/docs/theme.css +++ b/docs/theme.css @@ -20,7 +20,6 @@ body { color: var(--sa-text); } -/* a { color: var(--sa-primary); } @@ -41,6 +40,13 @@ select { color: var(--sa-text); } +code, +pre { + background-color: var(--sa-card); + border: 1px solid var(--sa-border); + color: var(--sa-text); +} + .card, .mintlify-card, .docs-card { @@ -64,4 +70,3 @@ select { .alert-danger { border-color: var(--sa-danger); } -*/ diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 838cc28..18186d6 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -29,25 +29,6 @@ Verify the agent is installed: ls -la ~/.local/share/sandbox-agent/bin/ ``` -### 4. Binary libc mismatch (musl vs glibc) - -Claude Code binaries are available in both musl and glibc variants. If you see errors like: - -``` -cannot execute: required file not found -Error loading shared library libstdc++.so.6: No such file or directory -``` - -This means the wrong binary variant was downloaded. - -**For sandbox-agent 0.2.0+**: Platform detection is automatic. The correct binary (musl or glibc) is downloaded based on the runtime environment. - -**For sandbox-agent 0.1.x**: Use Alpine Linux which has native musl support: - -```dockerfile -FROM alpine:latest -RUN apk add --no-cache curl ca-certificates libstdc++ libgcc bash -``` ## Daytona Network Restrictions diff --git a/examples/boxlite-python/Dockerfile b/examples/boxlite-python/Dockerfile index 3630511..8aba774 100644 --- a/examples/boxlite-python/Dockerfile +++ b/examples/boxlite-python/Dockerfile @@ -1,5 +1,5 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent codex diff --git a/examples/boxlite/Dockerfile b/examples/boxlite/Dockerfile index 3630511..8aba774 100644 --- a/examples/boxlite/Dockerfile +++ b/examples/boxlite/Dockerfile @@ -1,5 +1,5 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent codex diff --git a/examples/boxlite/tsconfig.json b/examples/boxlite/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/boxlite/tsconfig.json +++ b/examples/boxlite/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/cloudflare/Dockerfile b/examples/cloudflare/Dockerfile index d0796cb..738f8a2 100644 --- a/examples/cloudflare/Dockerfile +++ b/examples/cloudflare/Dockerfile @@ -1,7 +1,7 @@ FROM cloudflare/sandbox:0.7.0 # Install sandbox-agent -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh # Pre-install agents RUN sandbox-agent install-agent claude && \ diff --git a/examples/computesdk/tsconfig.json b/examples/computesdk/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/computesdk/tsconfig.json +++ b/examples/computesdk/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index b881113..9c4cf85 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -16,7 +16,6 @@ console.log(`UI: ${client.inspectorUrl}`); const session = await client.createSession({ agent: detectAgent(), - cwd: "/home/daytona", }); session.onEvent((event) => { diff --git a/examples/daytona/tsconfig.json b/examples/daytona/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/daytona/tsconfig.json +++ b/examples/daytona/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/docker/tsconfig.json b/examples/docker/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/docker/tsconfig.json +++ b/examples/docker/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/e2b.ts index bfd5bda..17762a2 100644 --- a/examples/e2b/src/e2b.ts +++ b/examples/e2b/src/e2b.ts @@ -17,8 +17,10 @@ export async function setupE2BSandboxAgent(): Promise<{ token?: string; cleanup: () => Promise; }> { + const template = process.env.E2B_TEMPLATE; const client = await SandboxAgent.start({ sandbox: e2b({ + template, create: { envs: collectEnvVars() }, }), }); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index c20ebaa..67b74dc 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -5,15 +5,15 @@ import { detectAgent } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const template = process.env.E2B_TEMPLATE; const client = await SandboxAgent.start({ // ✨ NEW ✨ - sandbox: e2b({ create: { envs } }), + sandbox: e2b({ template, create: { envs } }), }); const session = await client.createSession({ agent: detectAgent(), - cwd: "/home/user", }); session.onEvent((event) => { diff --git a/examples/e2b/tsconfig.json b/examples/e2b/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/e2b/tsconfig.json +++ b/examples/e2b/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/file-system/tsconfig.json b/examples/file-system/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/file-system/tsconfig.json +++ b/examples/file-system/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/mcp-custom-tool/tsconfig.json b/examples/mcp-custom-tool/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/mcp-custom-tool/tsconfig.json +++ b/examples/mcp-custom-tool/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/mcp/tsconfig.json b/examples/mcp/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/mcp/tsconfig.json +++ b/examples/mcp/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/modal/tsconfig.json b/examples/modal/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/modal/tsconfig.json +++ b/examples/modal/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/permissions/tsconfig.json b/examples/permissions/tsconfig.json index 9c9fe06..4eec283 100644 --- a/examples/permissions/tsconfig.json +++ b/examples/permissions/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], + "types": ["node"], "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, diff --git a/examples/persist-memory/tsconfig.json b/examples/persist-memory/tsconfig.json index d1c0065..ec2723c 100644 --- a/examples/persist-memory/tsconfig.json +++ b/examples/persist-memory/tsconfig.json @@ -1,13 +1,15 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true, "esModuleInterop": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] }, "include": ["src"] } diff --git a/examples/persist-postgres/tsconfig.json b/examples/persist-postgres/tsconfig.json index d1c0065..ec2723c 100644 --- a/examples/persist-postgres/tsconfig.json +++ b/examples/persist-postgres/tsconfig.json @@ -1,13 +1,15 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true, "esModuleInterop": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] }, "include": ["src"] } diff --git a/examples/persist-sqlite/tsconfig.json b/examples/persist-sqlite/tsconfig.json index d1c0065..ec2723c 100644 --- a/examples/persist-sqlite/tsconfig.json +++ b/examples/persist-sqlite/tsconfig.json @@ -1,13 +1,15 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true, "esModuleInterop": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] }, "include": ["src"] } diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index 96aa45a..f4161fb 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -9,7 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); /** Pre-built Docker image with all agents installed. */ -export const FULL_IMAGE = "rivetdev/sandbox-agent:0.4.1-rc.1-full"; +export const FULL_IMAGE = "rivetdev/sandbox-agent:0.4.2-full"; export interface DockerSandboxOptions { /** Container port used by sandbox-agent inside Docker. */ diff --git a/examples/skills-custom-tool/tsconfig.json b/examples/skills-custom-tool/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/skills-custom-tool/tsconfig.json +++ b/examples/skills-custom-tool/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/skills/tsconfig.json b/examples/skills/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/skills/tsconfig.json +++ b/examples/skills/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/examples/sprites/package.json b/examples/sprites/package.json new file mode 100644 index 0000000..df808e8 --- /dev/null +++ b/examples/sprites/package.json @@ -0,0 +1,20 @@ +{ + "name": "@sandbox-agent/example-sprites", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fly/sprites": "latest", + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest", + "vitest": "^3.0.0" + } +} diff --git a/examples/sprites/src/index.ts b/examples/sprites/src/index.ts new file mode 100644 index 0000000..bf95e5d --- /dev/null +++ b/examples/sprites/src/index.ts @@ -0,0 +1,21 @@ +import { SandboxAgent } from "sandbox-agent"; +import { sprites } from "sandbox-agent/sprites"; + +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const client = await SandboxAgent.start({ + sandbox: sprites({ + token: process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN, + env, + }), +}); + +console.log(`UI: ${client.inspectorUrl}`); +console.log(await client.getHealth()); + +process.once("SIGINT", async () => { + await client.destroySandbox(); + process.exit(0); +}); diff --git a/examples/sprites/tests/sprites.test.ts b/examples/sprites/tests/sprites.test.ts new file mode 100644 index 0000000..dfd1594 --- /dev/null +++ b/examples/sprites/tests/sprites.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { SandboxAgent } from "sandbox-agent"; +import { sprites } from "sandbox-agent/sprites"; + +const shouldRun = Boolean(process.env.SPRITES_API_KEY || process.env.SPRITE_TOKEN || process.env.SPRITES_TOKEN); +const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000; + +const testFn = shouldRun ? it : it.skip; + +describe("sprites provider", () => { + testFn( + "starts sandbox-agent and responds to /v1/health", + async () => { + const env: Record = {}; + if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + + const sdk = await SandboxAgent.start({ + sandbox: sprites({ + token: process.env.SPRITES_API_KEY ?? process.env.SPRITE_TOKEN ?? process.env.SPRITES_TOKEN, + env, + }), + }); + + try { + const health = await sdk.getHealth(); + expect(health.status).toBe("ok"); + } finally { + await sdk.destroySandbox(); + } + }, + timeoutMs, + ); +}); diff --git a/examples/sprites/tsconfig.json b/examples/sprites/tsconfig.json new file mode 100644 index 0000000..ad591c3 --- /dev/null +++ b/examples/sprites/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 9839893..5a83e0c 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -19,7 +19,6 @@ console.log(`UI: ${client.inspectorUrl}`); const session = await client.createSession({ agent: detectAgent(), - cwd: "/home/vercel-sandbox", }); session.onEvent((event) => { diff --git a/examples/vercel/tsconfig.json b/examples/vercel/tsconfig.json index 96ba2fd..ad591c3 100644 --- a/examples/vercel/tsconfig.json +++ b/examples/vercel/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 268b04c..2d9bcbb 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -59,6 +59,39 @@ Use `pnpm` workspaces and Turborepo. - The dev server has debug logging enabled by default (`RIVET_LOG_LEVEL=debug`, `FOUNDRY_LOG_LEVEL=debug`) via `compose.dev.yaml`. Error stacks and timestamps are also enabled. - The frontend client uses JSON encoding for RivetKit in development (`import.meta.env.DEV`) for easier debugging. Production uses the default encoding. +## Foundry Base Sandbox Image + +Local Docker sandboxes use the `rivetdev/sandbox-agent:foundry-base-latest` image by default. This image extends the sandbox-agent runtime with sudo, git, neovim, gh, node, bun, chromium, and agent-browser. + +- **Dockerfile:** `docker/foundry-base.Dockerfile` (builds sandbox-agent from source, x86_64 only) +- **Publish script:** `scripts/publish-foundry-base.sh` (builds and pushes to Docker Hub `rivetdev/sandbox-agent`) +- **Tags:** `foundry-base-TZ` (timestamped) + `foundry-base-latest` (rolling) +- **Build from repo root:** `./foundry/scripts/publish-foundry-base.sh` (or `--dry-run` to skip push) +- **Override image in dev:** set `HF_LOCAL_SANDBOX_IMAGE` in `foundry/.env` or environment. The env var is passed through `compose.dev.yaml` to the backend. +- **Resolution order:** `config.sandboxProviders.local.image` (config.toml) > `HF_LOCAL_SANDBOX_IMAGE` (env var) > `DEFAULT_LOCAL_SANDBOX_IMAGE` constant in `packages/backend/src/actors/sandbox/index.ts`. +- The image must be built with `--platform linux/amd64`. The Rust build is memory-intensive; Docker Desktop needs at least 8GB RAM allocated. +- When updating the base image contents (new system packages, agent versions), rebuild and push with the publish script, then update the `foundry-base-latest` tag. + +## Production GitHub App + OAuth App + +Foundry uses two separate GitHub entities in production: + +- **OAuth App** (`GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`) — handles "Sign in with GitHub" via Better Auth. This is a standard OAuth App. +- **GitHub App** (`GITHUB_APP_ID` / `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET` / `GITHUB_APP_PRIVATE_KEY`) — handles webhooks, installation tokens for repo access, and GitHub API sync (repos, PRs). Must be manually installed on each org. + +Key env vars and where they connect: + +- `GITHUB_REDIRECT_URI` — OAuth callback, must point to `https://api.sandboxagent.dev/v1/auth/callback/github` +- `GITHUB_WEBHOOK_SECRET` — must match the secret configured on the GitHub App's Webhook settings page exactly. Mismatches cause silent 500s on webhook delivery (signature verification fails inside the actor, surfaced as a generic RivetKit `internal_error`). +- `BETTER_AUTH_URL` — must be the **API** URL (`https://api.sandboxagent.dev`), not the frontend URL. Better Auth uses this internally for sign-out and session management calls. +- `APP_URL` — the **frontend** URL (`https://foundry.sandboxagent.dev`). + +Troubleshooting: + +- **"GitHub App not installed"** — The GitHub App must be manually installed on each org. Sign-in does not auto-install it. Go to the GitHub App settings → Install App tab. The sign-in flow can only detect existing installations, not create them. +- **Webhooks not arriving** — Check the GitHub App → Advanced tab for delivery history. If deliveries show 500, the webhook secret likely doesn't match `GITHUB_WEBHOOK_SECRET`. Test with: `echo -n '{"test":true}' | openssl dgst -sha256 -hmac "$SECRET"` and curl the endpoint with the computed signature. +- **Deleting all actors wipes GitHub App installation state.** After a full actor reset, you must trigger a webhook (e.g. redeliver from GitHub App Advanced tab, or re-install the app) to repopulate installation records. + ## Railway Logs - Production Foundry Railway logs can be read from a linked checkout with `railway logs --deployment --lines 200` or `railway logs --deployment --lines 200`. @@ -136,6 +169,14 @@ The client subscribes to `app` always, `organization` when entering an organizat - Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent organization actor, which broadcasts to organization subscribers. - Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone. +## Sandbox Architecture + +- Structurally, the system supports multiple sandboxes per task, but in practice there is exactly one active sandbox per task. Design features assuming one sandbox per task. If multi-sandbox is needed in the future, extend at that time. +- Each task has a **primary user** (owner) whose GitHub OAuth credentials are injected into the sandbox for git operations. The owner swaps when a different user sends a message. See `.context/proposal-task-owner-git-auth.md` for the full design. +- **Security: OAuth token scope.** The user's GitHub OAuth token has `repo` scope, granting full control of all private repositories the user has access to. When the user is the active task owner, their token is injected into the sandbox. This means the agent can read/write ANY repo the user has access to, not just the task's target repo. This is the standard trade-off for OAuth-based git integrations (same as GitHub Codespaces, Gitpod). The user consents to `repo` scope at sign-in time. Credential files in the sandbox are `chmod 600` and overwritten on owner swap. +- All git operations in the sandbox must be auto-authenticated. Never configure git to prompt for credentials (no interactive `GIT_ASKPASS` prompts). Use a credential store file that is pre-populated with the active owner's token. +- All git operation errors (push 401, clone failure, branch protection rejection) must surface in the UI with actionable context. Never silently swallow git errors. + ## Git State Policy - The backend stores zero git state. No local clones, no refs, no working trees, and no git-spice. @@ -191,16 +232,6 @@ For all Rivet/RivetKit implementation: - Example: the `task` actor instance already represents `(organizationId, repoId, taskId)`, so its SQLite tables should not need those columns for primary keys. 3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`). 4. The default dependency source for RivetKit is the published `rivetkit` package so monorepo installs and CI remain self-contained. -5. When working on coordinated RivetKit changes, you may temporarily relink to a local checkout instead of the published package. - - Dedicated local checkout for this repo: `/Users/nathan/conductor/workspaces/task/rivet-checkout` - - Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit` - - Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the RivetKit monorepo when using the local checkout. -6. Before using a local checkout, build RivetKit in the rivet repo: - ```bash - cd ../rivet-checkout/rivetkit-typescript - pnpm install - pnpm build -F rivetkit - ``` ## Rivet Routing diff --git a/foundry/compose.dev.yaml b/foundry/compose.dev.yaml index c57d971..7fa492d 100644 --- a/foundry/compose.dev.yaml +++ b/foundry/compose.dev.yaml @@ -44,6 +44,7 @@ services: STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}" STRIPE_PRICE_TEAM: "${STRIPE_PRICE_TEAM:-}" FOUNDRY_SANDBOX_PROVIDER: "${FOUNDRY_SANDBOX_PROVIDER:-local}" + HF_LOCAL_SANDBOX_IMAGE: "${HF_LOCAL_SANDBOX_IMAGE:-rivetdev/sandbox-agent:foundry-base-latest}" E2B_API_KEY: "${E2B_API_KEY:-}" E2B_TEMPLATE: "${E2B_TEMPLATE:-}" HF_E2B_TEMPLATE: "${HF_E2B_TEMPLATE:-${E2B_TEMPLATE:-}}" @@ -56,8 +57,6 @@ services: - "7741:7741" volumes: - "..:/app" - # The linked RivetKit checkout resolves from Foundry packages to /task/rivet-checkout in-container. - - "../../../task/rivet-checkout:/task/rivet-checkout:ro" # Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev. - "${HOME}/.codex:/root/.codex" - "/var/run/docker.sock:/var/run/docker.sock" @@ -86,7 +85,6 @@ services: - "..:/app" # Ensure logs in .foundry/ persist on the host even if we change source mounts later. - "./.foundry:/app/foundry/.foundry" - - "../../../task/rivet-checkout:/task/rivet-checkout:ro" # Use Linux-native repo dependencies inside the container instead of host node_modules. - "foundry_node_modules:/app/node_modules" - "foundry_client_node_modules:/app/foundry/packages/client/node_modules" diff --git a/foundry/compose.mock.yaml b/foundry/compose.mock.yaml index c4a06ff..6c57875 100644 --- a/foundry/compose.mock.yaml +++ b/foundry/compose.mock.yaml @@ -15,7 +15,6 @@ services: volumes: - "..:/app" - "./.foundry:/app/foundry/.foundry" - - "../../../task/rivet-checkout:/task/rivet-checkout:ro" - "mock_node_modules:/app/node_modules" - "mock_client_node_modules:/app/foundry/packages/client/node_modules" - "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules" diff --git a/foundry/docker/backend.Dockerfile b/foundry/docker/backend.Dockerfile index 3dc1c7d..ae14ddf 100644 --- a/foundry/docker/backend.Dockerfile +++ b/foundry/docker/backend.Dockerfile @@ -19,6 +19,7 @@ RUN pnpm --filter @sandbox-agent/foundry-backend deploy --prod /out FROM oven/bun:1.2 AS runtime ENV NODE_ENV=production ENV HOME=/home/task +ENV RIVET_RUNNER_VERSION_FILE=/etc/foundry/rivet-runner-version WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -31,6 +32,8 @@ RUN addgroup --system --gid 1001 task \ && adduser --system --uid 1001 --home /home/task --ingroup task task \ && mkdir -p /home/task \ && chown -R task:task /home/task /app +RUN mkdir -p /etc/foundry \ + && date +%s > /etc/foundry/rivet-runner-version COPY --from=build /out ./ USER task EXPOSE 7741 diff --git a/foundry/docker/backend.dev.Dockerfile b/foundry/docker/backend.dev.Dockerfile index 46177c3..c4b6c3a 100644 --- a/foundry/docker/backend.dev.Dockerfile +++ b/foundry/docker/backend.dev.Dockerfile @@ -21,6 +21,9 @@ RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION ENV PATH="/root/.local/bin:${PATH}" ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" +ENV RIVET_RUNNER_VERSION_FILE=/etc/foundry/rivet-runner-version +RUN mkdir -p /etc/foundry \ + && date +%s > /etc/foundry/rivet-runner-version WORKDIR /app diff --git a/foundry/docker/backend.preview.Dockerfile b/foundry/docker/backend.preview.Dockerfile index 00774f2..91cd7c7 100644 --- a/foundry/docker/backend.preview.Dockerfile +++ b/foundry/docker/backend.preview.Dockerfile @@ -20,11 +20,13 @@ RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION ENV PATH="/root/.local/bin:${PATH}" ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" +ENV RIVET_RUNNER_VERSION_FILE=/etc/foundry/rivet-runner-version +RUN mkdir -p /etc/foundry \ + && date +%s > /etc/foundry/rivet-runner-version WORKDIR /workspace/quebec COPY quebec /workspace/quebec -COPY rivet-checkout /workspace/rivet-checkout RUN pnpm install --frozen-lockfile RUN pnpm --filter @sandbox-agent/foundry-shared build diff --git a/foundry/docker/foundry-base.Dockerfile b/foundry/docker/foundry-base.Dockerfile new file mode 100644 index 0000000..b4b9e26 --- /dev/null +++ b/foundry/docker/foundry-base.Dockerfile @@ -0,0 +1,190 @@ +# syntax=docker/dockerfile:1.10.0 +# +# Foundry base sandbox image. +# +# Builds sandbox-agent from source (reusing the upstream Dockerfile.full build +# stages) and layers Foundry-specific tooling on top: sudo, git, neovim, gh, +# node, bun, chromium, and agent-browser. +# +# Build: +# docker build --platform linux/amd64 \ +# -f foundry/docker/foundry-base.Dockerfile \ +# -t rivetdev/sandbox-agent:foundry-base- . +# +# Must be invoked from the repository root so the COPY . picks up the full +# source tree for the Rust + inspector build stages. + +# ============================================================================ +# Build inspector frontend +# ============================================================================ +FROM --platform=linux/amd64 node:22-alpine AS inspector-build +WORKDIR /app +RUN npm install -g pnpm + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ +COPY sdks/cli-shared/package.json ./sdks/cli-shared/ +COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ +COPY sdks/react/package.json ./sdks/react/ +COPY sdks/typescript/package.json ./sdks/typescript/ + +RUN pnpm install --filter @sandbox-agent/inspector... + +COPY docs/openapi.json ./docs/ +COPY sdks/cli-shared ./sdks/cli-shared +COPY sdks/acp-http-client ./sdks/acp-http-client +COPY sdks/react ./sdks/react +COPY sdks/typescript ./sdks/typescript + +RUN cd sdks/cli-shared && pnpm exec tsup +RUN cd sdks/acp-http-client && pnpm exec tsup +RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup +RUN cd sdks/react && pnpm exec tsup + +COPY frontend/packages/inspector ./frontend/packages/inspector +RUN cd frontend/packages/inspector && pnpm exec vite build + +# ============================================================================ +# AMD64 Builder - sandbox-agent static binary +# ============================================================================ +FROM --platform=linux/amd64 rust:1.88.0 AS builder + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + musl-tools \ + musl-dev \ + llvm-14-dev \ + libclang-14-dev \ + clang-14 \ + libssl-dev \ + pkg-config \ + ca-certificates \ + g++ \ + g++-multilib \ + git \ + curl \ + wget && \ + rm -rf /var/lib/apt/lists/* + +RUN wget -q https://github.com/cross-tools/musl-cross/releases/latest/download/x86_64-unknown-linux-musl.tar.xz && \ + tar -xf x86_64-unknown-linux-musl.tar.xz -C /opt/ && \ + rm x86_64-unknown-linux-musl.tar.xz && \ + rustup target add x86_64-unknown-linux-musl + +ENV PATH="/opt/x86_64-unknown-linux-musl/bin:$PATH" \ + LIBCLANG_PATH=/usr/lib/llvm-14/lib \ + CLANG_PATH=/usr/bin/clang-14 \ + CC_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-gcc \ + CXX_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-g++ \ + AR_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-ar \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \ + CARGO_INCREMENTAL=0 \ + CARGO_NET_GIT_FETCH_WITH_CLI=true + +ENV SSL_VER=1.1.1w +RUN wget https://www.openssl.org/source/openssl-$SSL_VER.tar.gz && \ + tar -xzf openssl-$SSL_VER.tar.gz && \ + cd openssl-$SSL_VER && \ + ./Configure no-shared no-async --prefix=/musl --openssldir=/musl/ssl linux-x86_64 && \ + make -j$(nproc) && \ + make install_sw && \ + cd .. && \ + rm -rf openssl-$SSL_VER* + +ENV OPENSSL_DIR=/musl \ + OPENSSL_INCLUDE_DIR=/musl/include \ + OPENSSL_LIB_DIR=/musl/lib \ + PKG_CONFIG_ALLOW_CROSS=1 \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" + +WORKDIR /build +COPY . . + +COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/packages/inspector/dist + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release --target x86_64-unknown-linux-musl -j4 && \ + cp target/x86_64-unknown-linux-musl/release/sandbox-agent /sandbox-agent + +# ============================================================================ +# Runtime - Foundry base sandbox image +# ============================================================================ +FROM --platform=linux/amd64 node:22-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +# --- System packages -------------------------------------------------------- +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + gnupg \ + neovim \ + sudo \ + unzip \ + wget \ + # Chromium and its runtime deps + chromium \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +# --- GitHub CLI (gh) ------------------------------------------------------- +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update && apt-get install -y gh \ + && rm -rf /var/lib/apt/lists/* + +# --- Bun -------------------------------------------------------------------- +RUN curl -fsSL https://bun.sh/install | bash \ + && mv /root/.bun/bin/bun /usr/local/bin/bun \ + && ln -sf /usr/local/bin/bun /usr/local/bin/bunx \ + && rm -rf /root/.bun + +# --- sandbox-agent binary (from local build) -------------------------------- +COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent +RUN chmod +x /usr/local/bin/sandbox-agent + +# --- sandbox user with passwordless sudo ------------------------------------ +RUN useradd -m -s /bin/bash sandbox \ + && echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox \ + && chmod 0440 /etc/sudoers.d/sandbox + +USER sandbox +WORKDIR /home/sandbox + +# Point Chromium/Playwright at the system binary +ENV CHROME_PATH=/usr/bin/chromium +ENV CHROMIUM_PATH=/usr/bin/chromium +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# --- Install all sandbox-agent agents + agent-browser ----------------------- +RUN sandbox-agent install-agent --all +RUN sudo npm install -g agent-browser + +EXPOSE 2468 + +ENTRYPOINT ["sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] diff --git a/foundry/docker/frontend.preview.Dockerfile b/foundry/docker/frontend.preview.Dockerfile index 05cbba7..dd10422 100644 --- a/foundry/docker/frontend.preview.Dockerfile +++ b/foundry/docker/frontend.preview.Dockerfile @@ -7,7 +7,6 @@ RUN npm install -g pnpm@10.28.2 WORKDIR /workspace/quebec COPY quebec /workspace/quebec -COPY rivet-checkout /workspace/rivet-checkout RUN pnpm install --frozen-lockfile RUN pnpm --filter @sandbox-agent/foundry-shared build diff --git a/foundry/packages/backend/CLAUDE.md b/foundry/packages/backend/CLAUDE.md index ae4257e..f7e054d 100644 --- a/foundry/packages/backend/CLAUDE.md +++ b/foundry/packages/backend/CLAUDE.md @@ -49,6 +49,14 @@ OrganizationActor (coordinator for tasks + auth users) When adding a new index table, annotate it in the schema file with a doc comment identifying it as a coordinator index and which child actor it indexes (see existing examples). +## GitHub Sync Data Model + +The GithubDataActor syncs **repositories** and **pull requests** from GitHub, not branches. We only need repos (to know which repos exist and their metadata) and PRs (to lazily populate virtual tasks in the sidebar). Branch data is not synced because we only create tasks from PRs or fresh user-initiated creation, never from bare branches. Generated branch names for new tasks are treated as unique enough to skip conflict detection against remote branches. + +Tasks are either: +1. **Created fresh** by the user (no PR yet, branch name generated from task description) +2. **Lazily populated from pull requests** during PR sync (virtual task entries in org tables, no actor spawned) + ## Lazy Task Actor Creation — CRITICAL **Task actors must NEVER be created during GitHub sync or bulk operations.** Creating hundreds of task actors simultaneously causes OOM crashes. An org can have 200+ PRs; spawning an actor per PR kills the process. @@ -86,6 +94,46 @@ When the user interacts with a virtual task (clicks it, creates a session): - `refreshTaskSummaryForBranchMutation` — called in bulk during sync. Must ONLY write to org local tables. Never create task actors or call task actor actions. - `emitPullRequestChangeEvents` in github-data — iterates all changed PRs. Must remain fire-and-forget with no actor fan-out. +## Queue vs Action Decision Framework + +The default is a direct action. Use a queue only if the answer to one or more of these questions is **yes**. + +Actions are pure RPCs with no DB overhead on send — fast, but if the call fails the operation is lost. Queues persist the message to the database on send, guaranteeing it will be processed even if the target actor is busy, slow, or recovering. The tradeoff: queues add write overhead and serialize processing. + +### 1. Does this operation coordinate multi-step work? + +Does it involve external I/O (sandbox API, GitHub API, agent process management) or state machine transitions where interleaving would corrupt state? This is different from database-level serialization — a simple read-then-write on SQLite can use a transaction. The queue is for ordering operations that span DB writes + external I/O. + +**Queue examples:** +- `workspace.send_message` — sends to sandbox agent, writes session status, does owner-swap. Multi-step with external I/O. +- `push` / `sync` / `merge` — git operations in sandbox that must not interleave. +- `createTask` — read-then-write across task index + actor creation. Returns result, so `wait: true`. + +**Action examples:** +- `billing.stripe_customer.apply` — single column upsert, no external I/O. +- `workspace.update_draft` — writes draft text, no coordination with sandbox ops. +- `workspace.rename_task` — updates title column, queue handlers don't touch title. + +### 2. Must this message be processed no matter what? + +Is this a cross-actor fire-and-forget where the caller won't retry and data loss is unacceptable? A queue persists the message — if the target is down, it waits. An action RPC that fails is gone. + +**Queue examples:** +- `audit.append` — caller must never be affected by audit failures, and audit entries must not be lost. +- `applyTaskSummaryUpdate` — task actor pushes summary to org and moves on. Won't retry if org is busy. +- `refreshTaskSummaryForBranch` — webhook-driven, won't be redelivered for the same event. + +**Action examples:** +- `billing.invoice.upsert` — Stripe retries handle failures externally. No durability need on our side. +- `workspace.mark_unread` — UI convenience state. Acceptable to lose on transient failure. +- `github.webhook_receipt.record` — timestamp columns with no downstream effects. + +### Once on a queue: wait or fire-and-forget? + +If the caller needs a return value, use `wait: true`. If the UI updates via push events, use `wait: false`. + +Full migration plan: `QUEUE_TO_ACTION_MIGRATION.md`. + ## Ownership Rules - `OrganizationActor` is the organization coordinator, direct coordinator for tasks, and lookup/index owner. It owns the task index, task summaries, and repo catalog. @@ -198,6 +246,90 @@ curl -s -X POST 'http://127.0.0.1:6420/gateway//inspector/action/ { + const now = Date.now(); + await c.db + .insert(events) + .values({ + repoId: body.repoId ?? null, + taskId: body.taskId ?? null, + branchName: body.branchName ?? null, + kind: body.kind, + payloadJson: JSON.stringify(body.payload), + createdAt: now, + }) + .run(); + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Workflow command loop +// --------------------------------------------------------------------------- + +type AuditLogWorkflowHandler = (loopCtx: any, body: any) => Promise; + +const AUDIT_LOG_COMMAND_HANDLERS: Record = { + "auditLog.command.append": async (c, body) => appendMutation(c, body), +}; + +async function runAuditLogWorkflow(ctx: any): Promise { + await ctx.loop("audit-log-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-audit-log-command", { + names: [...AUDIT_LOG_QUEUE_NAMES], + completable: true, + }); + + if (!msg) { + return Loop.continue(undefined); + } + + const handler = AUDIT_LOG_COMMAND_HANDLERS[msg.name as AuditLogQueueName]; + if (!handler) { + logActorWarning("auditLog", "unknown audit-log command", { command: msg.name }); + await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {}); + return Loop.continue(undefined); + } + + try { + // Wrap in a step so c.state and c.db are accessible inside mutation functions. + const result = await loopCtx.step({ + name: msg.name, + timeout: 60_000, + run: async () => handler(loopCtx, msg.body), + }); + await msg.complete(result); + } catch (error) { + const message = resolveErrorMessage(error); + logActorWarning("auditLog", "audit-log workflow command failed", { + command: msg.name, + error: message, + }); + await msg.complete({ error: message }).catch(() => {}); + } + + return Loop.continue(undefined); + }); +} + +// --------------------------------------------------------------------------- +// Actor definition +// --------------------------------------------------------------------------- + /** * Organization-scoped audit log. One per org, not one per repo. * @@ -35,6 +123,7 @@ export interface ListAuditLogParams { */ export const auditLog = actor({ db: auditLogDb, + queues: Object.fromEntries(AUDIT_LOG_QUEUE_NAMES.map((name) => [name, queue()])), options: { name: "Audit Log", icon: "database", @@ -43,22 +132,14 @@ export const auditLog = actor({ organizationId: input.organizationId, }), actions: { - async append(c, body: AppendAuditLogCommand): Promise<{ ok: true }> { - const now = Date.now(); - await c.db - .insert(events) - .values({ - repoId: body.repoId ?? null, - taskId: body.taskId ?? null, - branchName: body.branchName ?? null, - kind: body.kind, - payloadJson: JSON.stringify(body.payload), - createdAt: now, - }) - .run(); + // Mutation — self-send to queue for workflow history + async append(c: any, body: AppendAuditLogCommand): Promise<{ ok: true }> { + const self = selfAuditLog(c); + await self.send(auditLogWorkflowQueueName("auditLog.command.append"), body, { wait: false }); return { ok: true }; }, + // Read — direct action (no queue) async list(c, params?: ListAuditLogParams): Promise { const whereParts = []; if (params?.repoId) { @@ -95,4 +176,5 @@ export const auditLog = actor({ })); }, }, + run: workflow(runAuditLogWorkflow), }); diff --git a/foundry/packages/backend/src/actors/github-data/db/migrations.ts b/foundry/packages/backend/src/actors/github-data/db/migrations.ts index 6584968..10e3804 100644 --- a/foundry/packages/backend/src/actors/github-data/db/migrations.ts +++ b/foundry/packages/backend/src/actors/github-data/db/migrations.ts @@ -24,6 +24,12 @@ const journal = { tag: "0003_sync_progress", breakpoints: true, }, + { + idx: 4, + when: 1773993600000, + tag: "0004_drop_github_branches", + breakpoints: true, + }, ], } as const; @@ -101,6 +107,8 @@ ALTER TABLE \`github_members\` ADD \`sync_generation\` integer NOT NULL DEFAULT ALTER TABLE \`github_pull_requests\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0; --> statement-breakpoint ALTER TABLE \`github_branches\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0; +`, + m0004: `DROP TABLE IF EXISTS \`github_branches\`; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/github-data/db/schema.ts b/foundry/packages/backend/src/actors/github-data/db/schema.ts index a11ac9a..94b4edc 100644 --- a/foundry/packages/backend/src/actors/github-data/db/schema.ts +++ b/foundry/packages/backend/src/actors/github-data/db/schema.ts @@ -30,15 +30,6 @@ export const githubRepositories = sqliteTable("github_repositories", { updatedAt: integer("updated_at").notNull(), }); -export const githubBranches = sqliteTable("github_branches", { - branchId: text("branch_id").notNull().primaryKey(), - repoId: text("repo_id").notNull(), - branchName: text("branch_name").notNull(), - commitSha: text("commit_sha").notNull(), - syncGeneration: integer("sync_generation").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - export const githubMembers = sqliteTable("github_members", { memberId: text("member_id").notNull().primaryKey(), login: text("login").notNull(), diff --git a/foundry/packages/backend/src/actors/github-data/index.ts b/foundry/packages/backend/src/actors/github-data/index.ts index a7d65a0..d19732a 100644 --- a/foundry/packages/backend/src/actors/github-data/index.ts +++ b/foundry/packages/backend/src/actors/github-data/index.ts @@ -1,20 +1,22 @@ // @ts-nocheck import { eq, inArray } from "drizzle-orm"; -import { actor } from "rivetkit"; +import { actor, queue } from "rivetkit"; +import { workflow, Loop } from "rivetkit/workflow"; import type { FoundryOrganization } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; import { getOrCreateOrganization, getTask } from "../handles.js"; +import { logActorWarning, resolveErrorMessage } from "../logging.js"; +import { taskWorkflowQueueName } from "../task/workflow/queue.js"; import { repoIdFromRemote } from "../../services/repo.js"; import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; -// actions called directly (no queue) +import { organizationWorkflowQueueName } from "../organization/queues.js"; import { githubDataDb } from "./db/db.js"; -import { githubBranches, githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js"; -// workflow.ts is no longer used — commands are actions now +import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js"; const META_ROW_ID = 1; const SYNC_REPOSITORY_BATCH_SIZE = 10; -type GithubSyncPhase = "discovering_repositories" | "syncing_repositories" | "syncing_branches" | "syncing_members" | "syncing_pull_requests"; +type GithubSyncPhase = "discovering_repositories" | "syncing_repositories" | "syncing_members" | "syncing_pull_requests"; interface GithubDataInput { organizationId: string; @@ -36,12 +38,6 @@ interface GithubRepositoryRecord { defaultBranch: string; } -interface GithubBranchRecord { - repoId: string; - branchName: string; - commitSha: string; -} - interface GithubPullRequestRecord { repoId: string; repoFullName: string; @@ -74,7 +70,18 @@ interface ClearStateInput { label: string; } -// sendOrganizationCommand removed — org actions called directly +// Queue names for github-data actor +export const GITHUB_DATA_QUEUE_NAMES = [ + "githubData.command.syncRepos", + "githubData.command.handlePullRequestWebhook", + "githubData.command.clearState", +] as const; + +type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number]; + +export function githubDataWorkflowQueueName(name: GithubDataQueueName): GithubDataQueueName { + return name; +} interface PullRequestWebhookInput { connectedAccount: string; @@ -209,18 +216,22 @@ async function writeMeta(c: any, patch: Partial) { async function publishSyncProgress(c: any, patch: Partial): Promise { const meta = await writeMeta(c, patch); const organization = await getOrCreateOrganization(c, c.state.organizationId); - await organization.commandApplyGithubSyncProgress({ - connectedAccount: meta.connectedAccount, - installationStatus: meta.installationStatus, - installationId: meta.installationId, - syncStatus: meta.syncStatus, - lastSyncLabel: meta.lastSyncLabel, - lastSyncAt: meta.lastSyncAt, - syncGeneration: meta.syncGeneration, - syncPhase: meta.syncPhase, - processedRepositoryCount: meta.processedRepositoryCount, - totalRepositoryCount: meta.totalRepositoryCount, - }); + await organization.send( + organizationWorkflowQueueName("organization.command.github.sync_progress.apply"), + { + connectedAccount: meta.connectedAccount, + installationStatus: meta.installationStatus, + installationId: meta.installationId, + syncStatus: meta.syncStatus, + lastSyncLabel: meta.lastSyncLabel, + lastSyncAt: meta.lastSyncAt, + syncGeneration: meta.syncGeneration, + syncPhase: meta.syncPhase, + processedRepositoryCount: meta.processedRepositoryCount, + totalRepositoryCount: meta.totalRepositoryCount, + }, + { wait: false }, + ); return meta; } @@ -290,42 +301,6 @@ async function sweepRepositories(c: any, syncGeneration: number) { } } -async function upsertBranches(c: any, branches: GithubBranchRecord[], updatedAt: number, syncGeneration: number) { - for (const branch of branches) { - await c.db - .insert(githubBranches) - .values({ - branchId: `${branch.repoId}:${branch.branchName}`, - repoId: branch.repoId, - branchName: branch.branchName, - commitSha: branch.commitSha, - syncGeneration, - updatedAt, - }) - .onConflictDoUpdate({ - target: githubBranches.branchId, - set: { - repoId: branch.repoId, - branchName: branch.branchName, - commitSha: branch.commitSha, - syncGeneration, - updatedAt, - }, - }) - .run(); - } -} - -async function sweepBranches(c: any, syncGeneration: number) { - const rows = await c.db.select({ branchId: githubBranches.branchId, syncGeneration: githubBranches.syncGeneration }).from(githubBranches).all(); - for (const row of rows) { - if (row.syncGeneration === syncGeneration) { - continue; - } - await c.db.delete(githubBranches).where(eq(githubBranches.branchId, row.branchId)).run(); - } -} - async function upsertMembers(c: any, members: GithubMemberRecord[], updatedAt: number, syncGeneration: number) { for (const member of members) { await c.db @@ -424,7 +399,13 @@ async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: s return; } const organization = await getOrCreateOrganization(c, c.state.organizationId); - void organization.commandRefreshTaskSummaryForBranch({ repoId, branchName, pullRequest, repoName: repositoryRecord.fullName ?? undefined }).catch(() => {}); + void organization + .send( + organizationWorkflowQueueName("organization.command.refreshTaskSummaryForBranch"), + { repoId, branchName, pullRequest, repoName: repositoryRecord.fullName ?? undefined }, + { wait: false }, + ) + .catch(() => {}); } async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) { @@ -472,7 +453,7 @@ async function autoArchiveTaskForClosedPullRequest(c: any, row: any) { } try { const task = getTask(c, c.state.organizationId, row.repoId, match.taskId); - void task.archive({ reason: `PR ${String(row.state).toLowerCase()}` }).catch(() => {}); + void task.send(taskWorkflowQueueName("task.command.archive"), { reason: `PR ${String(row.state).toLowerCase()}` }, { wait: false }).catch(() => {}); } catch { // Best-effort only. Task summary refresh will still clear the PR state. } @@ -578,63 +559,6 @@ async function listPullRequestsForRepositories( })); } -async function listRepositoryBranchesForContext( - context: Awaited>, - repository: GithubRepositoryRecord, -): Promise { - const { appShell } = getActorRuntimeContext(); - let branches: Array<{ name: string; commitSha: string }> = []; - - if (context.installationId != null) { - try { - branches = await appShell.github.listInstallationRepositoryBranches(context.installationId, repository.fullName); - } catch (error) { - if (!context.accessToken) { - throw error; - } - } - } - - if (branches.length === 0 && context.accessToken) { - branches = await appShell.github.listUserRepositoryBranches(context.accessToken, repository.fullName); - } - - const repoId = repoIdFromRemote(repository.cloneUrl); - return branches.map((branch) => ({ - repoId, - branchName: branch.name, - commitSha: branch.commitSha, - })); -} - -async function refreshRepositoryBranches( - c: any, - context: Awaited>, - repository: GithubRepositoryRecord, - updatedAt: number, -): Promise { - const currentMeta = await readMeta(c); - const nextBranches = await listRepositoryBranchesForContext(context, repository); - await c.db - .delete(githubBranches) - .where(eq(githubBranches.repoId, repoIdFromRemote(repository.cloneUrl))) - .run(); - - for (const branch of nextBranches) { - await c.db - .insert(githubBranches) - .values({ - branchId: `${branch.repoId}:${branch.branchName}`, - repoId: branch.repoId, - branchName: branch.branchName, - commitSha: branch.commitSha, - syncGeneration: currentMeta.syncGeneration, - updatedAt, - }) - .run(); - } -} - async function readAllPullRequestRows(c: any) { return await c.db.select().from(githubPullRequests).all(); } @@ -712,41 +636,7 @@ export async function fullSyncSetup(c: any, input: FullSyncInput = {}): Promise< } /** - * Phase 2 (per-batch): Fetch and upsert branches for one batch of repos. - * Returns true when all batches have been processed. - */ -export async function fullSyncBranchBatch(c: any, config: FullSyncConfig, batchIndex: number): Promise { - const repos = await readRepositoriesFromDb(c); - const batches = chunkItems(repos, SYNC_REPOSITORY_BATCH_SIZE); - if (batchIndex >= batches.length) return true; - - const batch = batches[batchIndex]!; - const context = await getOrganizationContext(c, { - connectedAccount: config.connectedAccount, - installationStatus: config.installationStatus as any, - installationId: config.installationId, - }); - const batchBranches = (await Promise.all(batch.map((repo) => listRepositoryBranchesForContext(context, repo)))).flat(); - await upsertBranches(c, batchBranches, config.startedAt, config.syncGeneration); - - const processedCount = Math.min((batchIndex + 1) * SYNC_REPOSITORY_BATCH_SIZE, repos.length); - await publishSyncProgress(c, { - connectedAccount: config.connectedAccount, - installationStatus: config.installationStatus, - installationId: config.installationId, - syncStatus: "syncing", - lastSyncLabel: `Synced branches for ${processedCount} of ${repos.length} repositories`, - syncGeneration: config.syncGeneration, - syncPhase: "syncing_branches", - processedRepositoryCount: processedCount, - totalRepositoryCount: repos.length, - }); - - return false; -} - -/** - * Phase 3: Resolve, upsert, and sweep members. + * Phase 2: Resolve, upsert, and sweep members. */ export async function fullSyncMembers(c: any, config: FullSyncConfig): Promise { await publishSyncProgress(c, { @@ -772,7 +662,7 @@ export async function fullSyncMembers(c: any, config: FullSyncConfig): Promise { @@ -806,10 +696,9 @@ export async function fullSyncPullRequestBatch(c: any, config: FullSyncConfig, b } /** - * Phase 5: Sweep stale data, publish final state, emit PR change events. + * Phase 4: Sweep stale data, publish final state, emit PR change events. */ export async function fullSyncFinalize(c: any, config: FullSyncConfig): Promise { - await sweepBranches(c, config.syncGeneration); await sweepPullRequests(c, config.syncGeneration); await sweepRepositories(c, config.syncGeneration); @@ -842,12 +731,6 @@ export async function fullSyncFinalize(c: any, config: FullSyncConfig): Promise< export async function runFullSync(c: any, input: FullSyncInput = {}): Promise { const config = await fullSyncSetup(c, input); - // Branches — native loop over batches - for (let i = 0; ; i++) { - const done = await fullSyncBranchBatch(c, config, i); - if (done) break; - } - // Members await fullSyncMembers(c, config); @@ -877,8 +760,78 @@ export async function fullSyncError(c: any, error: unknown): Promise { }); } +// --------------------------------------------------------------------------- +// Workflow command loop +// --------------------------------------------------------------------------- + +type GithubDataWorkflowHandler = (loopCtx: any, body: any) => Promise; + +const GITHUB_DATA_COMMAND_HANDLERS: Record = { + "githubData.command.syncRepos": async (c, body) => { + try { + await runFullSync(c, body); + return { ok: true }; + } catch (error) { + try { + await fullSyncError(c, error); + } catch { + /* best effort */ + } + throw error; + } + }, + "githubData.command.handlePullRequestWebhook": async (c, body) => { + await handlePullRequestWebhookMutation(c, body); + return { ok: true }; + }, + "githubData.command.clearState": async (c, body) => { + await clearStateMutation(c, body); + return { ok: true }; + }, +}; + +async function runGithubDataWorkflow(ctx: any): Promise { + await ctx.loop("github-data-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-github-data-command", { + names: [...GITHUB_DATA_QUEUE_NAMES], + completable: true, + }); + + if (!msg) { + return Loop.continue(undefined); + } + + const handler = GITHUB_DATA_COMMAND_HANDLERS[msg.name as GithubDataQueueName]; + if (!handler) { + logActorWarning("github-data", "unknown github-data command", { command: msg.name }); + await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {}); + return Loop.continue(undefined); + } + + try { + // Wrap in a step so c.state and c.db are accessible inside mutation functions. + const result = await loopCtx.step({ + name: msg.name, + timeout: 10 * 60_000, + run: async () => handler(loopCtx, msg.body), + }); + await msg.complete(result); + } catch (error) { + const message = resolveErrorMessage(error); + logActorWarning("github-data", "github-data workflow command failed", { + command: msg.name, + error: message, + }); + await msg.complete({ error: message }).catch(() => {}); + } + + return Loop.continue(undefined); + }); +} + export const githubData = actor({ db: githubDataDb, + queues: Object.fromEntries(GITHUB_DATA_QUEUE_NAMES.map((name) => [name, queue()])), options: { name: "GitHub Data", icon: "github", @@ -890,13 +843,11 @@ export const githubData = actor({ actions: { async getSummary(c) { const repositories = await c.db.select().from(githubRepositories).all(); - const branches = await c.db.select().from(githubBranches).all(); const members = await c.db.select().from(githubMembers).all(); const pullRequests = await c.db.select().from(githubPullRequests).all(); return { ...(await readMeta(c)), repositoryCount: repositories.length, - branchCount: branches.length, memberCount: members.length, pullRequestCount: pullRequests.length, }; @@ -935,115 +886,14 @@ export const githubData = actor({ .all(); return rows.map((row) => pullRequestSummaryFromRow(row)); }, - - async listBranchesForRepository(c, input: { repoId: string }) { - const rows = await c.db.select().from(githubBranches).where(eq(githubBranches.repoId, input.repoId)).all(); - return rows - .map((row) => ({ - branchName: row.branchName, - commitSha: row.commitSha, - })) - .sort((left, right) => left.branchName.localeCompare(right.branchName)); - }, - - async syncRepos(c, body: any) { - try { - await runFullSync(c, body); - return { ok: true }; - } catch (error) { - try { - await fullSyncError(c, error); - } catch { - /* best effort */ - } - throw error; - } - }, - - async reloadRepository(c, body: { repoId: string }) { - return await reloadRepositoryMutation(c, body); - }, - - async clearState(c, body: any) { - await clearStateMutation(c, body); - return { ok: true }; - }, - - async handlePullRequestWebhook(c, body: any) { - await handlePullRequestWebhookMutation(c, body); - return { ok: true }; - }, }, + run: workflow(runGithubDataWorkflow), }); -export async function reloadRepositoryMutation(c: any, input: { repoId: string }) { - const context = await getOrganizationContext(c); - const current = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get(); - if (!current) { - throw new Error(`Unknown GitHub repository: ${input.repoId}`); - } - const { appShell } = getActorRuntimeContext(); - const repository = - context.installationId != null - ? await appShell.github.getInstallationRepository(context.installationId, current.fullName) - : context.accessToken - ? await appShell.github.getUserRepository(context.accessToken, current.fullName) - : null; - if (!repository) { - throw new Error(`Unable to reload repository: ${current.fullName}`); - } - - const updatedAt = Date.now(); - const currentMeta = await readMeta(c); - await c.db - .insert(githubRepositories) - .values({ - repoId: input.repoId, - fullName: repository.fullName, - cloneUrl: repository.cloneUrl, - private: repository.private ? 1 : 0, - defaultBranch: repository.defaultBranch, - syncGeneration: currentMeta.syncGeneration, - updatedAt, - }) - .onConflictDoUpdate({ - target: githubRepositories.repoId, - set: { - fullName: repository.fullName, - cloneUrl: repository.cloneUrl, - private: repository.private ? 1 : 0, - defaultBranch: repository.defaultBranch, - syncGeneration: currentMeta.syncGeneration, - updatedAt, - }, - }) - .run(); - await refreshRepositoryBranches( - c, - context, - { - fullName: repository.fullName, - cloneUrl: repository.cloneUrl, - private: repository.private, - defaultBranch: repository.defaultBranch, - }, - updatedAt, - ); - - return { - repoId: input.repoId, - fullName: repository.fullName, - cloneUrl: repository.cloneUrl, - private: repository.private, - defaultBranch: repository.defaultBranch, - }; -} - export async function clearStateMutation(c: any, input: ClearStateInput) { const beforeRows = await readAllPullRequestRows(c); const currentMeta = await readMeta(c); await c.db.delete(githubPullRequests).run(); - await c.db.delete(githubBranches).run(); await c.db.delete(githubRepositories).run(); await c.db.delete(githubMembers).run(); await writeMeta(c, { diff --git a/foundry/packages/backend/src/actors/github-data/workflow.ts b/foundry/packages/backend/src/actors/github-data/workflow.ts index 3497381..11ece75 100644 --- a/foundry/packages/backend/src/actors/github-data/workflow.ts +++ b/foundry/packages/backend/src/actors/github-data/workflow.ts @@ -9,9 +9,8 @@ async function getIndexModule() { export const GITHUB_DATA_QUEUE_NAMES = [ "githubData.command.syncRepos", - "githubData.command.reloadRepository", - "githubData.command.clearState", "githubData.command.handlePullRequestWebhook", + "githubData.command.clearState", ] as const; export type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number]; @@ -46,10 +45,10 @@ export async function runGithubDataCommandLoop(c: any): Promise { continue; } - if (msg.name === "githubData.command.reloadRepository") { - const { reloadRepositoryMutation } = await getIndexModule(); - const result = await reloadRepositoryMutation(c, msg.body); - await msg.complete(result); + if (msg.name === "githubData.command.handlePullRequestWebhook") { + const { handlePullRequestWebhookMutation } = await getIndexModule(); + await handlePullRequestWebhookMutation(c, msg.body); + await msg.complete({ ok: true }); continue; } @@ -60,13 +59,6 @@ export async function runGithubDataCommandLoop(c: any): Promise { continue; } - if (msg.name === "githubData.command.handlePullRequestWebhook") { - const { handlePullRequestWebhookMutation } = await getIndexModule(); - await handlePullRequestWebhookMutation(c, msg.body); - await msg.complete({ ok: true }); - continue; - } - logActorWarning("githubData", "unknown queue message", { queueName: msg.name }); await msg.complete({ error: `Unknown command: ${msg.name}` }); } catch (error) { diff --git a/foundry/packages/backend/src/actors/handles.ts b/foundry/packages/backend/src/actors/handles.ts index 2cc83d9..5aa5715 100644 --- a/foundry/packages/backend/src/actors/handles.ts +++ b/foundry/packages/backend/src/actors/handles.ts @@ -79,3 +79,7 @@ export function selfUser(c: any) { export function selfGithubData(c: any) { return actorClient(c).githubData.getForId(c.actorId); } + +export function selfTaskSandbox(c: any) { + return actorClient(c).taskSandbox.getForId(c.actorId); +} diff --git a/foundry/packages/backend/src/actors/index.ts b/foundry/packages/backend/src/actors/index.ts index 52bb914..74ede4a 100644 --- a/foundry/packages/backend/src/actors/index.ts +++ b/foundry/packages/backend/src/actors/index.ts @@ -6,16 +6,15 @@ import { auditLog } from "./audit-log/index.js"; import { taskSandbox } from "./sandbox/index.js"; import { organization } from "./organization/index.js"; import { logger } from "../logging.js"; +import { resolveRunnerVersion } from "../config/runner-version.js"; -const RUNNER_VERSION = Math.floor(Date.now() / 1000); +const runnerVersion = resolveRunnerVersion(); export const registry = setup({ serverless: { basePath: "/v1/rivet", }, - runner: { - version: RUNNER_VERSION, - }, + runner: { version: runnerVersion }, logging: { baseLogger: logger, }, diff --git a/foundry/packages/backend/src/actors/logging.ts b/foundry/packages/backend/src/actors/logging.ts index afc7d37..a61685f 100644 --- a/foundry/packages/backend/src/actors/logging.ts +++ b/foundry/packages/backend/src/actors/logging.ts @@ -22,6 +22,16 @@ export function resolveErrorStack(error: unknown): string | undefined { return undefined; } +export function logActorInfo(scope: string, message: string, context?: Record): void { + logger.info( + { + scope, + ...(context ?? {}), + }, + message, + ); +} + export function logActorWarning(scope: string, message: string, context?: Record): void { logger.warn( { diff --git a/foundry/packages/backend/src/actors/organization/actions.ts b/foundry/packages/backend/src/actors/organization/actions.ts index 436765c..2298cd9 100644 --- a/foundry/packages/backend/src/actors/organization/actions.ts +++ b/foundry/packages/backend/src/actors/organization/actions.ts @@ -18,6 +18,7 @@ import { organizationOnboardingActions } from "./actions/onboarding.js"; import { organizationGithubActions } from "./actions/github.js"; import { organizationShellActions } from "./actions/organization.js"; import { organizationTaskActions } from "./actions/tasks.js"; +import { updateOrganizationShellProfileMutation } from "./app-shell.js"; interface OrganizationState { organizationId: string; @@ -169,6 +170,11 @@ export const organizationActions = { assertOrganization(c, input.organizationId); return await getOrganizationSummarySnapshot(c); }, + + // updateShellProfile stays as a direct action — called with await from HTTP handler where the user can retry + async updateShellProfile(c: any, input: { displayName?: string; slug?: string; primaryDomain?: string }): Promise { + await updateOrganizationShellProfileMutation(c, input); + }, }; export async function applyGithubSyncProgressMutation( diff --git a/foundry/packages/backend/src/actors/organization/actions/better-auth.ts b/foundry/packages/backend/src/actors/organization/actions/better-auth.ts index 37f34b4..060ceed 100644 --- a/foundry/packages/backend/src/actors/organization/actions/better-auth.ts +++ b/foundry/packages/backend/src/actors/organization/actions/better-auth.ts @@ -1,21 +1,4 @@ -import { - and, - asc, - count as sqlCount, - desc, - eq, - gt, - gte, - inArray, - isNotNull, - isNull, - like, - lt, - lte, - ne, - notInArray, - or, -} from "drizzle-orm"; +import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm"; import { authAccountIndex, authEmailIndex, authSessionIndex, authVerification } from "../db/schema.js"; import { APP_SHELL_ORGANIZATION_ID } from "../constants.js"; @@ -151,10 +134,7 @@ export async function betterAuthDeleteEmailIndexMutation(c: any, input: { email: await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run(); } -export async function betterAuthUpsertAccountIndexMutation( - c: any, - input: { id: string; providerId: string; accountId: string; userId: string }, -) { +export async function betterAuthUpsertAccountIndexMutation(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) { assertAppOrganization(c); const now = Date.now(); @@ -198,8 +178,15 @@ export async function betterAuthDeleteAccountIndexMutation(c: any, input: { id?: export async function betterAuthCreateVerificationMutation(c: any, input: { data: Record }) { assertAppOrganization(c); - await c.db.insert(authVerification).values(input.data as any).run(); - return await c.db.select().from(authVerification).where(eq(authVerification.id, input.data.id as string)).get(); + await c.db + .insert(authVerification) + .values(input.data as any) + .run(); + return await c.db + .select() + .from(authVerification) + .where(eq(authVerification.id, input.data.id as string)) + .get(); } export async function betterAuthUpdateVerificationMutation(c: any, input: { where: any[]; update: Record }) { @@ -209,7 +196,11 @@ export async function betterAuthUpdateVerificationMutation(c: any, input: { wher if (!predicate) { return null; } - await c.db.update(authVerification).set(input.update as any).where(predicate).run(); + await c.db + .update(authVerification) + .set(input.update as any) + .where(predicate) + .run(); return await c.db.select().from(authVerification).where(predicate).get(); } @@ -220,7 +211,11 @@ export async function betterAuthUpdateManyVerificationMutation(c: any, input: { if (!predicate) { return 0; } - await c.db.update(authVerification).set(input.update as any).where(predicate).run(); + await c.db + .update(authVerification) + .set(input.update as any) + .where(predicate) + .run(); const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get(); return row?.value ?? 0; } @@ -247,7 +242,49 @@ export async function betterAuthDeleteManyVerificationMutation(c: any, input: { return rows.length; } +// Exception to the CLAUDE.md queue-for-mutations rule: Better Auth adapter operations +// use direct actions even for mutations. Better Auth runs during OAuth callbacks on the +// HTTP request path, not through the normal organization lifecycle. Routing through the +// queue adds multiple sequential round-trips (each with actor wake-up + step overhead) +// that cause 30-second OAuth callbacks and proxy retry storms. These mutations are simple +// SQLite upserts/deletes with no cross-actor coordination or broadcast side effects. export const organizationBetterAuthActions = { + // --- Mutation actions (called by the Better Auth adapter in better-auth.ts) --- + async betterAuthUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) { + return await betterAuthUpsertSessionIndexMutation(c, input); + }, + async betterAuthDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) { + await betterAuthDeleteSessionIndexMutation(c, input); + }, + async betterAuthUpsertEmailIndex(c: any, input: { email: string; userId: string }) { + return await betterAuthUpsertEmailIndexMutation(c, input); + }, + async betterAuthDeleteEmailIndex(c: any, input: { email: string }) { + await betterAuthDeleteEmailIndexMutation(c, input); + }, + async betterAuthUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) { + return await betterAuthUpsertAccountIndexMutation(c, input); + }, + async betterAuthDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) { + await betterAuthDeleteAccountIndexMutation(c, input); + }, + async betterAuthCreateVerification(c: any, input: { data: Record }) { + return await betterAuthCreateVerificationMutation(c, input); + }, + async betterAuthUpdateVerification(c: any, input: { where: any[]; update: Record }) { + return await betterAuthUpdateVerificationMutation(c, input); + }, + async betterAuthUpdateManyVerification(c: any, input: { where: any[]; update: Record }) { + return await betterAuthUpdateManyVerificationMutation(c, input); + }, + async betterAuthDeleteVerification(c: any, input: { where: any[] }) { + await betterAuthDeleteVerificationMutation(c, input); + }, + async betterAuthDeleteManyVerification(c: any, input: { where: any[] }) { + return await betterAuthDeleteManyVerificationMutation(c, input); + }, + + // --- Read actions --- async betterAuthFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) { assertAppOrganization(c); diff --git a/foundry/packages/backend/src/actors/organization/actions/github.ts b/foundry/packages/backend/src/actors/organization/actions/github.ts index ff14d7e..43818c0 100644 --- a/foundry/packages/backend/src/actors/organization/actions/github.ts +++ b/foundry/packages/backend/src/actors/organization/actions/github.ts @@ -1,16 +1,12 @@ import { desc } from "drizzle-orm"; import type { FoundryAppSnapshot } from "@sandbox-agent/foundry-shared"; import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js"; +import { githubDataWorkflowQueueName } from "../../github-data/index.js"; import { authSessionIndex } from "../db/schema.js"; -import { - assertAppOrganization, - buildAppSnapshot, - requireEligibleOrganization, - requireSignedInSession, - markOrganizationSyncStartedMutation, -} from "../app-shell.js"; +import { assertAppOrganization, buildAppSnapshot, requireEligibleOrganization, requireSignedInSession } from "../app-shell.js"; import { getBetterAuthService } from "../../../services/better-auth.js"; import { refreshOrganizationSnapshotMutation } from "../actions.js"; +import { organizationWorkflowQueueName } from "../queues.js"; export const organizationGithubActions = { async resolveAppGithubToken( @@ -58,21 +54,27 @@ export const organizationGithubActions = { } const organizationHandle = await getOrCreateOrganization(c, input.organizationId); - await organizationHandle.commandMarkSyncStarted({ label: "Importing repository catalog..." }); - await organizationHandle.commandBroadcastSnapshot({}); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.shell.sync_started.mark"), + { label: "Importing repository catalog..." }, + { wait: false }, + ); + await organizationHandle.send(organizationWorkflowQueueName("organization.command.snapshot.broadcast"), {}, { wait: false }); - void githubData.syncRepos({ label: "Importing repository catalog..." }).catch(() => {}); + void githubData + .send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Importing repository catalog..." }, { wait: false }) + .catch(() => {}); return await buildAppSnapshot(c, input.sessionId); }, async adminReloadGithubOrganization(c: any): Promise { const githubData = await getOrCreateGithubData(c, c.state.organizationId); - await githubData.syncRepos({ label: "Reloading GitHub organization..." }); + await githubData.send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Reloading GitHub organization..." }, { wait: false }); }, - async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise { + async adminReloadGithubRepository(c: any, _input: { repoId: string }): Promise { const githubData = await getOrCreateGithubData(c, c.state.organizationId); - await githubData.reloadRepository(input); + await githubData.send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Reloading repository..." }, { wait: false }); }, }; diff --git a/foundry/packages/backend/src/actors/organization/actions/organization.ts b/foundry/packages/backend/src/actors/organization/actions/organization.ts index d38e113..9e1cbd6 100644 --- a/foundry/packages/backend/src/actors/organization/actions/organization.ts +++ b/foundry/packages/backend/src/actors/organization/actions/organization.ts @@ -1,7 +1,6 @@ import type { FoundryAppSnapshot, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared"; import { getBetterAuthService } from "../../../services/better-auth.js"; import { getOrCreateOrganization } from "../../handles.js"; -// actions called directly (no queue) import { assertAppOrganization, assertOrganizationShell, @@ -11,7 +10,6 @@ import { requireEligibleOrganization, requireSignedInSession, } from "../app-shell.js"; -// org queue names removed — using direct actions export const organizationShellActions = { async getAppSnapshot(c: any, input: { sessionId: string }): Promise { @@ -35,7 +33,7 @@ export const organizationShellActions = { const session = await requireSignedInSession(c, input.sessionId); requireEligibleOrganization(session, input.organizationId); const organization = await getOrCreateOrganization(c, input.organizationId); - await organization.commandUpdateShellProfile({ + await organization.updateShellProfile({ displayName: input.displayName, slug: input.slug, primaryDomain: input.primaryDomain, diff --git a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts index 73abea2..3affccd 100644 --- a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts +++ b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts @@ -17,6 +17,8 @@ import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../../service // actions return directly (no queue response unwrapping) import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../../logging.js"; import { defaultSandboxProviderId } from "../../../sandbox-config.js"; +import { taskWorkflowQueueName } from "../../task/workflow/queue.js"; +import { expectQueueResponse } from "../../../services/queue.js"; import { taskIndex, taskSummaries } from "../db/schema.js"; import { refreshOrganizationSnapshotMutation } from "../actions.js"; @@ -33,7 +35,6 @@ interface RegisterTaskBranchCommand { repoId: string; taskId: string; branchName: string; - requireExistingRemote?: boolean; } function isStaleTaskReferenceError(error: unknown): boolean { @@ -64,6 +65,8 @@ function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) { branch: taskSummary.branch, pullRequestJson: JSON.stringify(taskSummary.pullRequest), sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + primaryUserLogin: taskSummary.primaryUserLogin ?? null, + primaryUserAvatarUrl: taskSummary.primaryUserAvatarUrl ?? null, }; } @@ -78,6 +81,8 @@ export function taskSummaryFromRow(repoId: string, row: any): WorkspaceTaskSumma branch: row.branch ?? null, pullRequest: parseJsonValue(row.pullRequestJson, null), sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + primaryUserLogin: row.primaryUserLogin ?? null, + primaryUserAvatarUrl: row.primaryUserAvatarUrl ?? null, }; } @@ -114,11 +119,6 @@ async function resolveGitHubRepository(c: any, repoId: string) { return await githubData.getRepository({ repoId }).catch(() => null); } -async function listGitHubBranches(c: any, repoId: string): Promise> { - const githubData = getGithubData(c, c.state.organizationId); - return await githubData.listBranchesForRepository({ repoId }).catch(() => []); -} - async function resolveRepositoryRemoteUrl(c: any, repoId: string): Promise { const repository = await resolveGitHubRepository(c, repoId); const remoteUrl = repository?.cloneUrl?.trim(); @@ -155,7 +155,6 @@ export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promis repoId, taskId, branchName: onBranch, - requireExistingRemote: true, }); } else { const reservedBranches = await listKnownTaskBranches(c, repoId); @@ -198,12 +197,18 @@ export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promis throw error; } - const created = await taskHandle.initialize({ - sandboxProviderId: cmd.sandboxProviderId, - branchName: initialBranchName, - title: initialTitle, - task: cmd.task, - }); + const created = expectQueueResponse( + await taskHandle.send( + taskWorkflowQueueName("task.command.initialize"), + { + sandboxProviderId: cmd.sandboxProviderId, + branchName: initialBranchName, + title: initialTitle, + task: cmd.task, + }, + { wait: true, timeout: 10_000 }, + ), + ); try { await upsertTaskSummary(c, await taskHandle.getTaskSummary({})); @@ -243,7 +248,7 @@ export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promis return created; } -export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> { +export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string }> { const branchName = cmd.branchName.trim(); if (!branchName) { throw new Error("branchName is required"); @@ -272,16 +277,6 @@ export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranch } } - const branches = await listGitHubBranches(c, cmd.repoId); - const branchMatch = branches.find((branch) => branch.branchName === branchName) ?? null; - if (cmd.requireExistingRemote && !branchMatch) { - throw new Error(`Remote branch not found: ${branchName}`); - } - - const repository = await resolveGitHubRepository(c, cmd.repoId); - const defaultBranch = repository?.defaultBranch ?? "main"; - const headSha = branchMatch?.commitSha ?? branches.find((branch) => branch.branchName === defaultBranch)?.commitSha ?? ""; - const now = Date.now(); await c.db .insert(taskIndex) @@ -301,7 +296,7 @@ export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranch }) .run(); - return { branchName, headSha }; + return { branchName }; } export async function applyTaskSummaryUpdateMutation(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise { @@ -380,7 +375,7 @@ export async function refreshTaskSummaryForBranchMutation( // Best-effort notify the task actor if it exists (fire-and-forget) try { const task = getTask(c, c.state.organizationId, input.repoId, row.taskId); - void task.pullRequestSync({ pullRequest }).catch(() => {}); + void task.syncPullRequest({ pullRequest }).catch(() => {}); } catch { // Task actor doesn't exist yet — that's fine, it's virtual } @@ -390,34 +385,6 @@ export async function refreshTaskSummaryForBranchMutation( await refreshOrganizationSnapshotMutation(c); } -export function sortOverviewBranches( - branches: Array<{ - branchName: string; - commitSha: string; - taskId: string | null; - taskTitle: string | null; - taskStatus: TaskRecord["status"] | null; - pullRequest: WorkspacePullRequestSummary | null; - ciStatus: string | null; - updatedAt: number; - }>, - defaultBranch: string | null, -) { - return [...branches].sort((left, right) => { - if (defaultBranch) { - if (left.branchName === defaultBranch && right.branchName !== defaultBranch) return -1; - if (right.branchName === defaultBranch && left.branchName !== defaultBranch) return 1; - } - if (Boolean(left.taskId) !== Boolean(right.taskId)) { - return left.taskId ? -1 : 1; - } - if (left.updatedAt !== right.updatedAt) { - return right.updatedAt - left.updatedAt; - } - return left.branchName.localeCompare(right.branchName); - }); -} - export async function listTaskSummariesForRepo(c: any, repoId: string, includeArchived = false): Promise { const rows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoId)).orderBy(desc(taskSummaries.updatedAtMs)).all(); return rows @@ -459,56 +426,24 @@ export async function getRepoOverviewFromOrg(c: any, repoId: string): Promise []); const taskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoId)).all(); - const taskMetaByBranch = new Map< - string, - { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number; pullRequest: WorkspacePullRequestSummary | null } - >(); - for (const row of taskRows) { - if (!row.branch) { - continue; - } - taskMetaByBranch.set(row.branch, { - taskId: row.taskId, - title: row.title ?? null, - status: row.status, - updatedAt: row.updatedAtMs, - pullRequest: parseJsonValue(row.pullRequestJson, null), - }); - } - - const branchMap = new Map(); - for (const branch of githubBranches) { - branchMap.set(branch.branchName, branch); - } - for (const branchName of taskMetaByBranch.keys()) { - if (!branchMap.has(branchName)) { - branchMap.set(branchName, { branchName, commitSha: "" }); - } - } - if (repository?.defaultBranch && !branchMap.has(repository.defaultBranch)) { - branchMap.set(repository.defaultBranch, { branchName: repository.defaultBranch, commitSha: "" }); - } - - const branches = sortOverviewBranches( - [...branchMap.values()].map((branch) => { - const taskMeta = taskMetaByBranch.get(branch.branchName); - const pr = taskMeta?.pullRequest ?? null; + const branches = taskRows + .filter((row: any) => row.branch) + .map((row: any) => { + const pr = parseJsonValue(row.pullRequestJson, null); return { - branchName: branch.branchName, - commitSha: branch.commitSha, - taskId: taskMeta?.taskId ?? null, - taskTitle: taskMeta?.title ?? null, - taskStatus: taskMeta?.status ?? null, + branchName: row.branch!, + commitSha: "", + taskId: row.taskId, + taskTitle: row.title ?? null, + taskStatus: row.status ?? null, pullRequest: pr, ciStatus: null, - updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now), + updatedAt: Math.max(row.updatedAtMs ?? 0, pr?.updatedAtMs ?? 0, now), }; - }), - repository?.defaultBranch ?? null, - ); + }) + .sort((a: any, b: any) => b.updatedAt - a.updatedAt); return { organizationId: c.state.organizationId, diff --git a/foundry/packages/backend/src/actors/organization/actions/tasks.ts b/foundry/packages/backend/src/actors/organization/actions/tasks.ts index 118ff15..80bb2f9 100644 --- a/foundry/packages/backend/src/actors/organization/actions/tasks.ts +++ b/foundry/packages/backend/src/actors/organization/actions/tasks.ts @@ -10,6 +10,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceDiffInput, TaskWorkspaceRenameInput, @@ -24,6 +25,8 @@ import { getActorRuntimeContext } from "../../context.js"; import { getOrCreateAuditLog, getOrCreateTask, getTask as getTaskHandle } from "../../handles.js"; import { defaultSandboxProviderId } from "../../../sandbox-config.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; +import { taskWorkflowQueueName } from "../../task/workflow/queue.js"; +import { expectQueueResponse } from "../../../services/queue.js"; import { taskIndex, taskSummaries } from "../db/schema.js"; import { createTaskMutation, @@ -130,11 +133,15 @@ export const organizationTaskActions = { const task = await requireWorkspaceTask(c, input.repoId, created.taskId); void task - .createSessionAndSend({ - model: input.model, - text: input.task, - authSessionId: input.authSessionId, - }) + .send( + taskWorkflowQueueName("task.command.workspace.create_session_and_send"), + { + model: input.model, + text: input.task, + authSessionId: input.authSessionId, + }, + { wait: false }, + ) .catch(() => {}); return { taskId: created.taskId }; @@ -152,15 +159,21 @@ export const organizationTaskActions = { async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); - return await task.createSession({ - ...(input.model ? { model: input.model } : {}), - ...(input.authSessionId ? { authSessionId: input.authSessionId } : {}), - }); + return expectQueueResponse( + await task.send( + taskWorkflowQueueName("task.command.workspace.create_session"), + { + ...(input.model ? { model: input.model } : {}), + ...(input.authSessionId ? { authSessionId: input.authSessionId } : {}), + }, + { wait: true, timeout: 10_000 }, + ), + ); }, async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); - await task.renameSession({ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId }); + await task.renameSession({ sessionId: input.sessionId, title: input.title }); }, async selectWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise { @@ -193,33 +206,55 @@ export const organizationTaskActions = { async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); void task - .sendMessage({ - sessionId: input.sessionId, - text: input.text, - attachments: input.attachments, - authSessionId: input.authSessionId, - }) + .send( + taskWorkflowQueueName("task.command.workspace.send_message"), + { + sessionId: input.sessionId, + text: input.text, + attachments: input.attachments, + authSessionId: input.authSessionId, + }, + { wait: false }, + ) .catch(() => {}); }, async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); - void task.stopSession({ sessionId: input.sessionId, authSessionId: input.authSessionId }).catch(() => {}); + void task + .send(taskWorkflowQueueName("task.command.workspace.stop_session"), { sessionId: input.sessionId, authSessionId: input.authSessionId }, { wait: false }) + .catch(() => {}); }, async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); - void task.closeSession({ sessionId: input.sessionId, authSessionId: input.authSessionId }).catch(() => {}); + void task + .send(taskWorkflowQueueName("task.command.workspace.close_session"), { sessionId: input.sessionId, authSessionId: input.authSessionId }, { wait: false }) + .catch(() => {}); }, async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); - void task.publishPr({}).catch(() => {}); + void task.send(taskWorkflowQueueName("task.command.workspace.publish_pr"), {}, { wait: false }).catch(() => {}); + }, + + async changeWorkspaceTaskOwner(c: any, input: TaskWorkspaceChangeOwnerInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.send( + taskWorkflowQueueName("task.command.workspace.change_owner"), + { + primaryUserId: input.targetUserId, + primaryGithubLogin: input.targetUserName, + primaryGithubEmail: input.targetUserEmail, + primaryGithubAvatarUrl: null, + }, + { wait: false }, + ); }, async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); - void task.revertFile(input).catch(() => {}); + void task.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, { wait: false }).catch(() => {}); }, async getRepoOverview(c: any, input: RepoOverviewInput): Promise { @@ -239,7 +274,9 @@ export const organizationTaskActions = { async switchTask(c: any, input: { repoId: string; taskId: string }): Promise { const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); const record = await h.get(); - const switched = await h.switchTask({}); + const switched = expectQueueResponse<{ switchTarget: string | null }>( + await h.send(taskWorkflowQueueName("task.command.switch"), {}, { wait: true, timeout: 10_000 }), + ); return { organizationId: c.state.organizationId, taskId: input.taskId, @@ -277,42 +314,42 @@ export const organizationTaskActions = { assertOrganization(c, input.organizationId); const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); - return await h.attach({ reason: input.reason }); + return expectQueueResponse(await h.send(taskWorkflowQueueName("task.command.attach"), { reason: input.reason }, { wait: true, timeout: 10_000 })); }, async pushTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); - void h.push({ reason: input.reason }).catch(() => {}); + void h.send(taskWorkflowQueueName("task.command.push"), { reason: input.reason }, { wait: false }).catch(() => {}); }, async syncTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); - void h.sync({ reason: input.reason }).catch(() => {}); + void h.send(taskWorkflowQueueName("task.command.sync"), { reason: input.reason }, { wait: false }).catch(() => {}); }, async mergeTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); - void h.merge({ reason: input.reason }).catch(() => {}); + void h.send(taskWorkflowQueueName("task.command.merge"), { reason: input.reason }, { wait: false }).catch(() => {}); }, async archiveTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); - void h.archive({ reason: input.reason }).catch(() => {}); + void h.send(taskWorkflowQueueName("task.command.archive"), { reason: input.reason }, { wait: false }).catch(() => {}); }, async killTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId); - void h.kill({ reason: input.reason }).catch(() => {}); + void h.send(taskWorkflowQueueName("task.command.kill"), { reason: input.reason }, { wait: false }).catch(() => {}); }, async getRepositoryMetadata(c: any, input: { repoId: string }): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> { diff --git a/foundry/packages/backend/src/actors/organization/app-shell.ts b/foundry/packages/backend/src/actors/organization/app-shell.ts index dce5855..ed1005a 100644 --- a/foundry/packages/backend/src/actors/organization/app-shell.ts +++ b/foundry/packages/backend/src/actors/organization/app-shell.ts @@ -15,8 +15,10 @@ import { getActorRuntimeContext } from "../context.js"; import { getOrCreateGithubData, getOrCreateOrganization, selfOrganization } from "../handles.js"; import { GitHubAppError } from "../../services/app-github.js"; import { getBetterAuthService } from "../../services/better-auth.js"; -import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js"; +import { repoLabelFromRemote } from "../../services/repo.js"; import { logger } from "../../logging.js"; +import { githubDataWorkflowQueueName } from "../github-data/index.js"; +import { organizationWorkflowQueueName } from "./queues.js"; import { invoices, organizationMembers, organizationProfile, seatAssignments, stripeLookup } from "./db/schema.js"; import { APP_SHELL_ORGANIZATION_ID } from "./constants.js"; @@ -482,19 +484,23 @@ async function syncGithubOrganizationsInternal(c: any, input: { sessionId: strin const organizationId = organizationOrganizationId(account.kind, account.githubLogin); const installation = installations.find((candidate) => candidate.accountLogin === account.githubLogin) ?? null; const organization = await getOrCreateOrganization(c, organizationId); - await organization.commandSyncOrganizationShellFromGithub({ - userId: githubUserId, - userName: viewer.name || viewer.login, - userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, - githubUserLogin: viewer.login, - githubAccountId: account.githubAccountId, - githubLogin: account.githubLogin, - githubAccountType: account.githubAccountType, - kind: account.kind, - displayName: account.displayName, - installationId: installation?.id ?? null, - appConfigured: appShell.github.isAppConfigured(), - }); + await organization.send( + organizationWorkflowQueueName("organization.command.github.organization_shell.sync_from_github"), + { + userId: githubUserId, + userName: viewer.name || viewer.login, + userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, + githubUserLogin: viewer.login, + githubAccountId: account.githubAccountId, + githubLogin: account.githubLogin, + githubAccountType: account.githubAccountType, + kind: account.kind, + displayName: account.displayName, + installationId: installation?.id ?? null, + appConfigured: appShell.github.isAppConfigured(), + }, + { wait: true, timeout: 10_000 }, + ); linkedOrganizationIds.push(organizationId); } @@ -677,10 +683,11 @@ async function applySubscriptionState( }, fallbackPlanId: FoundryBillingPlanId, ): Promise { - await organization.commandApplyStripeSubscription({ - subscription, - fallbackPlanId, - }); + await organization.send( + organizationWorkflowQueueName("organization.command.billing.stripe_subscription.apply"), + { subscription, fallbackPlanId }, + { wait: true, timeout: 10_000 }, + ); } export const organizationAppActions = { @@ -693,9 +700,11 @@ export const organizationAppActions = { const organizationState = await getOrganizationState(organizationHandle); if (input.planId === "free") { - await organizationHandle.commandApplyFreePlan({ - clearSubscription: false, - }); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.billing.free_plan.apply"), + { clearSubscription: false }, + { wait: true, timeout: 10_000 }, + ); return { url: `${appShell.appUrl}/organizations/${input.organizationId}/billing`, }; @@ -714,9 +723,11 @@ export const organizationAppActions = { email: session.currentUserEmail, }) ).id; - await organizationHandle.commandApplyStripeCustomer({ - customerId, - }); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.billing.stripe_customer.apply"), + { customerId }, + { wait: true, timeout: 10_000 }, + ); await upsertStripeLookupEntries(c, input.organizationId, customerId, null); } @@ -744,9 +755,11 @@ export const organizationAppActions = { const completion = await appShell.stripe.retrieveCheckoutCompletion(input.checkoutSessionId); if (completion.customerId) { - await organizationHandle.commandApplyStripeCustomer({ - customerId: completion.customerId, - }); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.billing.stripe_customer.apply"), + { customerId: completion.customerId }, + { wait: true, timeout: 10_000 }, + ); } await upsertStripeLookupEntries(c, input.organizationId, completion.customerId, completion.subscriptionId); @@ -756,9 +769,11 @@ export const organizationAppActions = { } if (completion.paymentMethodLabel) { - await organizationHandle.commandSetPaymentMethod({ - label: completion.paymentMethodLabel, - }); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.billing.payment_method.set"), + { label: completion.paymentMethodLabel }, + { wait: true, timeout: 10_000 }, + ); } return { @@ -796,9 +811,11 @@ export const organizationAppActions = { await applySubscriptionState(organizationHandle, subscription, organizationState.billingPlanId); await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organizationState.stripeCustomerId, subscription.id); } else { - await organizationHandle.commandSetBillingStatus({ - status: "scheduled_cancel", - }); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.billing.status.set"), + { status: "scheduled_cancel" }, + { wait: true, timeout: 10_000 }, + ); } return await buildAppSnapshot(c, input.sessionId); @@ -817,9 +834,11 @@ export const organizationAppActions = { await applySubscriptionState(organizationHandle, subscription, organizationState.billingPlanId); await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organizationState.stripeCustomerId, subscription.id); } else { - await organizationHandle.commandSetBillingStatus({ - status: "active", - }); + await organizationHandle.send( + organizationWorkflowQueueName("organization.command.billing.status.set"), + { status: "active" }, + { wait: true, timeout: 10_000 }, + ); } return await buildAppSnapshot(c, input.sessionId); @@ -830,9 +849,11 @@ export const organizationAppActions = { const session = await requireSignedInSession(c, input.sessionId); requireEligibleOrganization(session, input.organizationId); const organization = await getOrCreateOrganization(c, input.organizationId); - await organization.commandRecordSeatUsage({ - email: session.currentUserEmail, - }); + await organization.send( + organizationWorkflowQueueName("organization.command.billing.seat_usage.record"), + { email: session.currentUserEmail }, + { wait: true, timeout: 10_000 }, + ); return await buildAppSnapshot(c, input.sessionId); }, @@ -853,9 +874,11 @@ export const organizationAppActions = { if (organizationId) { const organization = await getOrCreateOrganization(c, organizationId); if (typeof object.customer === "string") { - await organization.commandApplyStripeCustomer({ - customerId: object.customer, - }); + await organization.send( + organizationWorkflowQueueName("organization.command.billing.stripe_customer.apply"), + { customerId: object.customer }, + { wait: true, timeout: 10_000 }, + ); } await upsertStripeLookupEntries( c, @@ -888,9 +911,11 @@ export const organizationAppActions = { const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id); if (organizationId) { const organization = await getOrCreateOrganization(c, organizationId); - await organization.commandApplyFreePlan({ - clearSubscription: true, - }); + await organization.send( + organizationWorkflowQueueName("organization.command.billing.free_plan.apply"), + { clearSubscription: true }, + { wait: true, timeout: 10_000 }, + ); } return { ok: true }; } @@ -902,13 +927,17 @@ export const organizationAppActions = { const organization = await getOrCreateOrganization(c, organizationId); const rawAmount = typeof invoice.amount_paid === "number" ? invoice.amount_paid : invoice.amount_due; const amountUsd = Math.round((typeof rawAmount === "number" ? rawAmount : 0) / 100); - await organization.commandUpsertInvoice({ - id: String(invoice.id), - label: typeof invoice.number === "string" ? `Invoice ${invoice.number}` : "Stripe invoice", - issuedAt: formatUnixDate(typeof invoice.created === "number" ? invoice.created : Math.floor(Date.now() / 1000)), - amountUsd: Number.isFinite(amountUsd) ? amountUsd : 0, - status: event.type === "invoice.paid" ? "paid" : "open", - }); + await organization.send( + organizationWorkflowQueueName("organization.command.billing.invoice.upsert"), + { + id: String(invoice.id), + label: typeof invoice.number === "string" ? `Invoice ${invoice.number}` : "Stripe invoice", + issuedAt: formatUnixDate(typeof invoice.created === "number" ? invoice.created : Math.floor(Date.now() / 1000)), + amountUsd: Number.isFinite(amountUsd) ? amountUsd : 0, + status: event.type === "invoice.paid" ? "paid" : "open", + }, + { wait: true, timeout: 10_000 }, + ); } } @@ -938,12 +967,11 @@ export const organizationAppActions = { const organizationId = organizationOrganizationId(kind, accountLogin); const receivedAt = Date.now(); const organization = await getOrCreateOrganization(c, organizationId); - await organization.commandRecordGithubWebhookReceipt({ - organizationId: organizationId, - event, - action: body.action ?? null, - receivedAt, - }); + await organization.send( + organizationWorkflowQueueName("organization.command.github.webhook_receipt.record"), + { organizationId, event, action: body.action ?? null, receivedAt }, + { wait: false }, + ); const githubData = await getOrCreateGithubData(c, organizationId); if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) { @@ -957,40 +985,51 @@ export const organizationAppActions = { "installation_event", ); if (body.action === "deleted") { - await githubData.clearState({ - connectedAccount: accountLogin, - installationStatus: "install_required", - installationId: null, - label: "GitHub App installation removed", - }); + await githubData.send( + githubDataWorkflowQueueName("githubData.command.clearState"), + { connectedAccount: accountLogin, installationStatus: "install_required", installationId: null, label: "GitHub App installation removed" }, + { wait: false }, + ); } else if (body.action === "created") { void githubData - .syncRepos({ - connectedAccount: accountLogin, - installationStatus: "connected", - installationId: body.installation?.id ?? null, - githubLogin: accountLogin, - kind, - label: "Syncing GitHub data from installation webhook...", - }) + .send( + githubDataWorkflowQueueName("githubData.command.syncRepos"), + { + connectedAccount: accountLogin, + installationStatus: "connected", + installationId: body.installation?.id ?? null, + githubLogin: accountLogin, + kind, + label: "Syncing GitHub data from installation webhook...", + }, + { wait: false }, + ) .catch(() => {}); } else if (body.action === "suspend") { - await githubData.clearState({ - connectedAccount: accountLogin, - installationStatus: "reconnect_required", - installationId: body.installation?.id ?? null, - label: "GitHub App installation suspended", - }); + await githubData.send( + githubDataWorkflowQueueName("githubData.command.clearState"), + { + connectedAccount: accountLogin, + installationStatus: "reconnect_required", + installationId: body.installation?.id ?? null, + label: "GitHub App installation suspended", + }, + { wait: false }, + ); } else if (body.action === "unsuspend") { void githubData - .syncRepos({ - connectedAccount: accountLogin, - installationStatus: "connected", - installationId: body.installation?.id ?? null, - githubLogin: accountLogin, - kind, - label: "Resyncing GitHub data after unsuspend...", - }) + .send( + githubDataWorkflowQueueName("githubData.command.syncRepos"), + { + connectedAccount: accountLogin, + installationStatus: "connected", + installationId: body.installation?.id ?? null, + githubLogin: accountLogin, + kind, + label: "Resyncing GitHub data after unsuspend...", + }, + { wait: false }, + ) .catch(() => {}); } return { ok: true }; @@ -1009,14 +1048,18 @@ export const organizationAppActions = { "repository_membership_changed", ); void githubData - .syncRepos({ - connectedAccount: accountLogin, - installationStatus: "connected", - installationId: body.installation?.id ?? null, - githubLogin: accountLogin, - kind, - label: "Resyncing GitHub data after repository access change...", - }) + .send( + githubDataWorkflowQueueName("githubData.command.syncRepos"), + { + connectedAccount: accountLogin, + installationStatus: "connected", + installationId: body.installation?.id ?? null, + githubLogin: accountLogin, + kind, + label: "Resyncing GitHub data after repository access change...", + }, + { wait: false }, + ) .catch(() => {}); return { ok: true }; } @@ -1045,36 +1088,33 @@ export const organizationAppActions = { "repository_event", ); if (event === "pull_request" && body.repository?.clone_url && body.pull_request) { - await githubData.handlePullRequestWebhook({ - connectedAccount: accountLogin, - installationStatus: "connected", - installationId: body.installation?.id ?? null, - repository: { - fullName: body.repository.full_name, - cloneUrl: body.repository.clone_url, - private: Boolean(body.repository.private), + await githubData.send( + githubDataWorkflowQueueName("githubData.command.handlePullRequestWebhook"), + { + connectedAccount: accountLogin, + installationStatus: "connected", + installationId: body.installation?.id ?? null, + repository: { + fullName: body.repository.full_name, + cloneUrl: body.repository.clone_url, + private: Boolean(body.repository.private), + }, + pullRequest: { + number: body.pull_request.number, + status: body.pull_request.draft ? "draft" : "ready", + title: body.pull_request.title ?? "", + body: body.pull_request.body ?? null, + state: body.pull_request.state ?? "open", + url: body.pull_request.html_url ?? `https://github.com/${body.repository.full_name}/pull/${body.pull_request.number}`, + headRefName: body.pull_request.head?.ref ?? "", + baseRefName: body.pull_request.base?.ref ?? "", + authorLogin: body.pull_request.user?.login ?? null, + isDraft: Boolean(body.pull_request.draft), + merged: Boolean(body.pull_request.merged), + }, }, - pullRequest: { - number: body.pull_request.number, - status: body.pull_request.draft ? "draft" : "ready", - title: body.pull_request.title ?? "", - body: body.pull_request.body ?? null, - state: body.pull_request.state ?? "open", - url: body.pull_request.html_url ?? `https://github.com/${body.repository.full_name}/pull/${body.pull_request.number}`, - headRefName: body.pull_request.head?.ref ?? "", - baseRefName: body.pull_request.base?.ref ?? "", - authorLogin: body.pull_request.user?.login ?? null, - isDraft: Boolean(body.pull_request.draft), - merged: Boolean(body.pull_request.merged), - }, - }); - } - if ((event === "push" || event === "create" || event === "delete") && body.repository?.clone_url) { - const repoId = repoIdFromRemote(body.repository.clone_url); - const knownRepository = await githubData.getRepository({ repoId }); - if (knownRepository) { - await githubData.reloadRepository({ repoId }); - } + { wait: false }, + ); } } return { ok: true }; @@ -1232,14 +1272,18 @@ export async function syncOrganizationShellFromGithubMutation( if (needsInitialSync) { const githubData = await getOrCreateGithubData(c, organizationId); void githubData - .syncRepos({ - connectedAccount: input.githubLogin, - installationStatus: "connected", - installationId: input.installationId, - githubLogin: input.githubLogin, - kind: input.kind, - label: "Initial repository sync...", - }) + .send( + githubDataWorkflowQueueName("githubData.command.syncRepos"), + { + connectedAccount: input.githubLogin, + installationStatus: "connected", + installationId: input.installationId, + githubLogin: input.githubLogin, + kind: input.kind, + label: "Initial repository sync...", + }, + { wait: false }, + ) .catch(() => {}); } diff --git a/foundry/packages/backend/src/actors/organization/db/drizzle/0001_add_auth_and_task_tables.sql b/foundry/packages/backend/src/actors/organization/db/drizzle/0001_add_auth_and_task_tables.sql index 74d63ef..fcd1b60 100644 --- a/foundry/packages/backend/src/actors/organization/db/drizzle/0001_add_auth_and_task_tables.sql +++ b/foundry/packages/backend/src/actors/organization/db/drizzle/0001_add_auth_and_task_tables.sql @@ -1,4 +1,4 @@ -CREATE TABLE `auth_session_index` ( +CREATE TABLE IF NOT EXISTS `auth_session_index` ( `session_id` text PRIMARY KEY NOT NULL, `session_token` text NOT NULL, `user_id` text NOT NULL, @@ -6,13 +6,13 @@ CREATE TABLE `auth_session_index` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `auth_email_index` ( +CREATE TABLE IF NOT EXISTS `auth_email_index` ( `email` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL, `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `auth_account_index` ( +CREATE TABLE IF NOT EXISTS `auth_account_index` ( `id` text PRIMARY KEY NOT NULL, `provider_id` text NOT NULL, `account_id` text NOT NULL, @@ -20,7 +20,7 @@ CREATE TABLE `auth_account_index` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `auth_verification` ( +CREATE TABLE IF NOT EXISTS `auth_verification` ( `id` text PRIMARY KEY NOT NULL, `identifier` text NOT NULL, `value` text NOT NULL, @@ -29,7 +29,7 @@ CREATE TABLE `auth_verification` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `task_index` ( +CREATE TABLE IF NOT EXISTS `task_index` ( `task_id` text PRIMARY KEY NOT NULL, `repo_id` text NOT NULL, `branch_name` text, @@ -37,7 +37,7 @@ CREATE TABLE `task_index` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `task_summaries` ( +CREATE TABLE IF NOT EXISTS `task_summaries` ( `task_id` text PRIMARY KEY NOT NULL, `repo_id` text NOT NULL, `title` text NOT NULL, diff --git a/foundry/packages/backend/src/actors/organization/db/migrations.ts b/foundry/packages/backend/src/actors/organization/db/migrations.ts index a7e8abc..2e8570b 100644 --- a/foundry/packages/backend/src/actors/organization/db/migrations.ts +++ b/foundry/packages/backend/src/actors/organization/db/migrations.ts @@ -16,6 +16,12 @@ const journal = { tag: "0001_add_auth_and_task_tables", breakpoints: true, }, + { + idx: 2, + when: 1773984000000, + tag: "0002_add_task_owner_columns", + breakpoints: true, + }, ], } as const; @@ -115,7 +121,7 @@ CREATE TABLE \`stripe_lookup\` ( \`updated_at\` integer NOT NULL ); `, - m0001: `CREATE TABLE \`auth_session_index\` ( + m0001: `CREATE TABLE IF NOT EXISTS \`auth_session_index\` ( \`session_id\` text PRIMARY KEY NOT NULL, \`session_token\` text NOT NULL, \`user_id\` text NOT NULL, @@ -123,13 +129,13 @@ CREATE TABLE \`stripe_lookup\` ( \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`auth_email_index\` ( +CREATE TABLE IF NOT EXISTS \`auth_email_index\` ( \`email\` text PRIMARY KEY NOT NULL, \`user_id\` text NOT NULL, \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`auth_account_index\` ( +CREATE TABLE IF NOT EXISTS \`auth_account_index\` ( \`id\` text PRIMARY KEY NOT NULL, \`provider_id\` text NOT NULL, \`account_id\` text NOT NULL, @@ -137,7 +143,7 @@ CREATE TABLE \`auth_account_index\` ( \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`auth_verification\` ( +CREATE TABLE IF NOT EXISTS \`auth_verification\` ( \`id\` text PRIMARY KEY NOT NULL, \`identifier\` text NOT NULL, \`value\` text NOT NULL, @@ -146,7 +152,7 @@ CREATE TABLE \`auth_verification\` ( \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`task_index\` ( +CREATE TABLE IF NOT EXISTS \`task_index\` ( \`task_id\` text PRIMARY KEY NOT NULL, \`repo_id\` text NOT NULL, \`branch_name\` text, @@ -154,7 +160,7 @@ CREATE TABLE \`task_index\` ( \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`task_summaries\` ( +CREATE TABLE IF NOT EXISTS \`task_summaries\` ( \`task_id\` text PRIMARY KEY NOT NULL, \`repo_id\` text NOT NULL, \`title\` text NOT NULL, @@ -165,6 +171,10 @@ CREATE TABLE \`task_summaries\` ( \`pull_request_json\` text, \`sessions_summary_json\` text DEFAULT '[]' NOT NULL ); +`, + m0002: `ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_login\` text; +--> statement-breakpoint +ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_avatar_url\` text; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/organization/db/schema.ts b/foundry/packages/backend/src/actors/organization/db/schema.ts index 5071a25..3978a5f 100644 --- a/foundry/packages/backend/src/actors/organization/db/schema.ts +++ b/foundry/packages/backend/src/actors/organization/db/schema.ts @@ -40,6 +40,8 @@ export const taskSummaries = sqliteTable("task_summaries", { branch: text("branch"), pullRequestJson: text("pull_request_json"), sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), + primaryUserLogin: text("primary_user_login"), + primaryUserAvatarUrl: text("primary_user_avatar_url"), }); export const organizationProfile = sqliteTable( diff --git a/foundry/packages/backend/src/actors/organization/index.ts b/foundry/packages/backend/src/actors/organization/index.ts index 1bd8896..9ceb27f 100644 --- a/foundry/packages/backend/src/actors/organization/index.ts +++ b/foundry/packages/backend/src/actors/organization/index.ts @@ -1,10 +1,13 @@ -import { actor } from "rivetkit"; +import { actor, queue } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; import { organizationDb } from "./db/db.js"; import { organizationActions } from "./actions.js"; -import { organizationCommandActions } from "./workflow.js"; +import { runOrganizationWorkflow } from "./workflow.js"; +import { ORGANIZATION_QUEUE_NAMES } from "./queues.js"; export const organization = actor({ db: organizationDb, + queues: Object.fromEntries(ORGANIZATION_QUEUE_NAMES.map((name) => [name, queue()])), options: { name: "Organization", icon: "compass", @@ -15,6 +18,6 @@ export const organization = actor({ }), actions: { ...organizationActions, - ...organizationCommandActions, }, + run: workflow(runOrganizationWorkflow), }); diff --git a/foundry/packages/backend/src/actors/organization/queues.ts b/foundry/packages/backend/src/actors/organization/queues.ts index f84e818..2e67dc5 100644 --- a/foundry/packages/backend/src/actors/organization/queues.ts +++ b/foundry/packages/backend/src/actors/organization/queues.ts @@ -1,27 +1,14 @@ export const ORGANIZATION_QUEUE_NAMES = [ "organization.command.createTask", "organization.command.materializeTask", - "organization.command.registerTaskBranch", "organization.command.applyTaskSummaryUpdate", "organization.command.removeTaskSummary", "organization.command.refreshTaskSummaryForBranch", "organization.command.snapshot.broadcast", "organization.command.syncGithubSession", - "organization.command.better_auth.session_index.upsert", - "organization.command.better_auth.session_index.delete", - "organization.command.better_auth.email_index.upsert", - "organization.command.better_auth.email_index.delete", - "organization.command.better_auth.account_index.upsert", - "organization.command.better_auth.account_index.delete", - "organization.command.better_auth.verification.create", - "organization.command.better_auth.verification.update", - "organization.command.better_auth.verification.update_many", - "organization.command.better_auth.verification.delete", - "organization.command.better_auth.verification.delete_many", + "organization.command.github.organization_shell.sync_from_github", "organization.command.github.sync_progress.apply", "organization.command.github.webhook_receipt.record", - "organization.command.github.organization_shell.sync_from_github", - "organization.command.shell.profile.update", "organization.command.shell.sync_started.mark", "organization.command.billing.stripe_customer.apply", "organization.command.billing.stripe_subscription.apply", diff --git a/foundry/packages/backend/src/actors/organization/workflow.ts b/foundry/packages/backend/src/actors/organization/workflow.ts index 189225b..e62e80d 100644 --- a/foundry/packages/backend/src/actors/organization/workflow.ts +++ b/foundry/packages/backend/src/actors/organization/workflow.ts @@ -1,29 +1,24 @@ // @ts-nocheck /** - * Organization command actions — converted from queue handlers to direct actions. - * Each export becomes an action on the organization actor. + * Organization workflow — queue-based command loop. + * + * Mutations are dispatched through named queues and processed inside workflow + * steps so that every command appears in the RivetKit inspector's workflow + * history. Read actions remain direct (no queue). + * + * Callers send commands directly via `.send()` to the appropriate queue name. */ +import { Loop } from "rivetkit/workflow"; +import { logActorWarning, resolveErrorMessage } from "../logging.js"; +import { ORGANIZATION_QUEUE_NAMES, type OrganizationQueueName } from "./queues.js"; + import { applyGithubSyncProgressMutation, recordGithubWebhookReceiptMutation, refreshOrganizationSnapshotMutation } from "./actions.js"; import { applyTaskSummaryUpdateMutation, createTaskMutation, refreshTaskSummaryForBranchMutation, - registerTaskBranchMutation, removeTaskSummaryMutation, } from "./actions/task-mutations.js"; -import { - betterAuthCreateVerificationMutation, - betterAuthDeleteAccountIndexMutation, - betterAuthDeleteEmailIndexMutation, - betterAuthDeleteManyVerificationMutation, - betterAuthDeleteSessionIndexMutation, - betterAuthDeleteVerificationMutation, - betterAuthUpdateManyVerificationMutation, - betterAuthUpdateVerificationMutation, - betterAuthUpsertAccountIndexMutation, - betterAuthUpsertEmailIndexMutation, - betterAuthUpsertSessionIndexMutation, -} from "./actions/better-auth.js"; import { applyOrganizationFreePlanMutation, applyOrganizationStripeCustomerMutation, @@ -33,131 +28,137 @@ import { setOrganizationBillingPaymentMethodMutation, setOrganizationBillingStatusMutation, syncOrganizationShellFromGithubMutation, - updateOrganizationShellProfileMutation, upsertOrganizationInvoiceMutation, } from "./app-shell.js"; -export const organizationCommandActions = { - async commandCreateTask(c: any, body: any) { - return await createTaskMutation(c, body); - }, - async commandMaterializeTask(c: any, body: any) { - return await createTaskMutation(c, body); - }, - async commandRegisterTaskBranch(c: any, body: any) { - return await registerTaskBranchMutation(c, body); - }, - async commandApplyTaskSummaryUpdate(c: any, body: any) { +// --------------------------------------------------------------------------- +// Workflow command loop — runs inside `run: workflow(runOrganizationWorkflow)` +// --------------------------------------------------------------------------- + +type WorkflowHandler = (loopCtx: any, body: any) => Promise; + +/** + * Maps queue names to their mutation handlers. + * Each handler receives the workflow loop context and the message body, + * executes the mutation, and returns the result (which is sent back via + * msg.complete). + */ +const COMMAND_HANDLERS: Record = { + // Task mutations + "organization.command.createTask": async (c, body) => createTaskMutation(c, body), + "organization.command.materializeTask": async (c, body) => createTaskMutation(c, body), + "organization.command.applyTaskSummaryUpdate": async (c, body) => { await applyTaskSummaryUpdateMutation(c, body); return { ok: true }; }, - async commandRemoveTaskSummary(c: any, body: any) { + "organization.command.removeTaskSummary": async (c, body) => { await removeTaskSummaryMutation(c, body); return { ok: true }; }, - async commandRefreshTaskSummaryForBranch(c: any, body: any) { + "organization.command.refreshTaskSummaryForBranch": async (c, body) => { await refreshTaskSummaryForBranchMutation(c, body); return { ok: true }; }, - async commandBroadcastSnapshot(c: any, _body: any) { + "organization.command.snapshot.broadcast": async (c, _body) => { await refreshOrganizationSnapshotMutation(c); return { ok: true }; }, - async commandSyncGithubSession(c: any, body: any) { + "organization.command.syncGithubSession": async (c, body) => { const { syncGithubOrganizations } = await import("./app-shell.js"); await syncGithubOrganizations(c, body); return { ok: true }; }, - // Better Auth index actions - async commandBetterAuthSessionIndexUpsert(c: any, body: any) { - return await betterAuthUpsertSessionIndexMutation(c, body); - }, - async commandBetterAuthSessionIndexDelete(c: any, body: any) { - await betterAuthDeleteSessionIndexMutation(c, body); - return { ok: true }; - }, - async commandBetterAuthEmailIndexUpsert(c: any, body: any) { - return await betterAuthUpsertEmailIndexMutation(c, body); - }, - async commandBetterAuthEmailIndexDelete(c: any, body: any) { - await betterAuthDeleteEmailIndexMutation(c, body); - return { ok: true }; - }, - async commandBetterAuthAccountIndexUpsert(c: any, body: any) { - return await betterAuthUpsertAccountIndexMutation(c, body); - }, - async commandBetterAuthAccountIndexDelete(c: any, body: any) { - await betterAuthDeleteAccountIndexMutation(c, body); - return { ok: true }; - }, - async commandBetterAuthVerificationCreate(c: any, body: any) { - return await betterAuthCreateVerificationMutation(c, body); - }, - async commandBetterAuthVerificationUpdate(c: any, body: any) { - return await betterAuthUpdateVerificationMutation(c, body); - }, - async commandBetterAuthVerificationUpdateMany(c: any, body: any) { - return await betterAuthUpdateManyVerificationMutation(c, body); - }, - async commandBetterAuthVerificationDelete(c: any, body: any) { - await betterAuthDeleteVerificationMutation(c, body); - return { ok: true }; - }, - async commandBetterAuthVerificationDeleteMany(c: any, body: any) { - return await betterAuthDeleteManyVerificationMutation(c, body); - }, + // GitHub organization shell sync (stays on queue) + "organization.command.github.organization_shell.sync_from_github": async (c, body) => syncOrganizationShellFromGithubMutation(c, body), - // GitHub sync actions - async commandApplyGithubSyncProgress(c: any, body: any) { + // GitHub sync progress + webhook receipt + "organization.command.github.sync_progress.apply": async (c, body) => { await applyGithubSyncProgressMutation(c, body); return { ok: true }; }, - async commandRecordGithubWebhookReceipt(c: any, body: any) { + "organization.command.github.webhook_receipt.record": async (c, body) => { await recordGithubWebhookReceiptMutation(c, body); return { ok: true }; }, - async commandSyncOrganizationShellFromGithub(c: any, body: any) { - return await syncOrganizationShellFromGithubMutation(c, body); - }, - - // Shell/profile actions - async commandUpdateShellProfile(c: any, body: any) { - await updateOrganizationShellProfileMutation(c, body); - return { ok: true }; - }, - async commandMarkSyncStarted(c: any, body: any) { + "organization.command.shell.sync_started.mark": async (c, body) => { await markOrganizationSyncStartedMutation(c, body); return { ok: true }; }, - // Billing actions - async commandApplyStripeCustomer(c: any, body: any) { + // Billing mutations + "organization.command.billing.stripe_customer.apply": async (c, body) => { await applyOrganizationStripeCustomerMutation(c, body); return { ok: true }; }, - async commandApplyStripeSubscription(c: any, body: any) { + "organization.command.billing.stripe_subscription.apply": async (c, body) => { await applyOrganizationStripeSubscriptionMutation(c, body); return { ok: true }; }, - async commandApplyFreePlan(c: any, body: any) { + "organization.command.billing.free_plan.apply": async (c, body) => { await applyOrganizationFreePlanMutation(c, body); return { ok: true }; }, - async commandSetPaymentMethod(c: any, body: any) { + "organization.command.billing.payment_method.set": async (c, body) => { await setOrganizationBillingPaymentMethodMutation(c, body); return { ok: true }; }, - async commandSetBillingStatus(c: any, body: any) { + "organization.command.billing.status.set": async (c, body) => { await setOrganizationBillingStatusMutation(c, body); return { ok: true }; }, - async commandUpsertInvoice(c: any, body: any) { + "organization.command.billing.invoice.upsert": async (c, body) => { await upsertOrganizationInvoiceMutation(c, body); return { ok: true }; }, - async commandRecordSeatUsage(c: any, body: any) { + "organization.command.billing.seat_usage.record": async (c, body) => { await recordOrganizationSeatUsageMutation(c, body); return { ok: true }; }, }; + +export async function runOrganizationWorkflow(ctx: any): Promise { + await ctx.loop("organization-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-organization-command", { + names: [...ORGANIZATION_QUEUE_NAMES], + completable: true, + }); + + if (!msg) { + return Loop.continue(undefined); + } + + const handler = COMMAND_HANDLERS[msg.name as OrganizationQueueName]; + if (!handler) { + logActorWarning("organization", "unknown organization command", { command: msg.name }); + await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {}); + return Loop.continue(undefined); + } + + try { + // Wrap in a step so c.state and c.db are accessible inside mutation functions. + const result = await loopCtx.step({ + name: msg.name, + timeout: 10 * 60_000, + run: async () => handler(loopCtx, msg.body), + }); + try { + await msg.complete(result); + } catch (completeError) { + logActorWarning("organization", "organization workflow failed completing response", { + command: msg.name, + error: resolveErrorMessage(completeError), + }); + } + } catch (error) { + const message = resolveErrorMessage(error); + logActorWarning("organization", "organization workflow command failed", { + command: msg.name, + error: message, + }); + await msg.complete({ error: message }).catch(() => {}); + } + + return Loop.continue(undefined); + }); +} diff --git a/foundry/packages/backend/src/actors/sandbox/index.ts b/foundry/packages/backend/src/actors/sandbox/index.ts index a35a149..0444d9b 100644 --- a/foundry/packages/backend/src/actors/sandbox/index.ts +++ b/foundry/packages/backend/src/actors/sandbox/index.ts @@ -1,4 +1,6 @@ -import { actor } from "rivetkit"; +// @ts-nocheck +import { actor, queue } from "rivetkit"; +import { workflow, Loop } from "rivetkit/workflow"; import { e2b, sandboxActor } from "rivetkit/sandbox"; import { existsSync } from "node:fs"; import Dockerode from "dockerode"; @@ -6,11 +8,18 @@ import { DEFAULT_WORKSPACE_MODEL_GROUPS, workspaceModelGroupsFromSandboxAgents, import { SandboxAgent } from "sandbox-agent"; import { getActorRuntimeContext } from "../context.js"; import { organizationKey } from "../keys.js"; +import { selfTaskSandbox } from "../handles.js"; import { logActorWarning, resolveErrorMessage } from "../logging.js"; +import { expectQueueResponse } from "../../services/queue.js"; import { resolveSandboxProviderId } from "../../sandbox-config.js"; -const SANDBOX_REPO_CWD = "/home/user/repo"; -const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:full"; +/** + * Default repo CWD inside the sandbox. The actual path is resolved dynamically + * via `$HOME/repo` because different sandbox providers run as different users + * (e.g. E2B uses `/home/user`, local Docker uses `/home/sandbox`). + */ +const DEFAULT_SANDBOX_REPO_CWD = "/home/user/repo"; +const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:foundry-base-latest"; const DEFAULT_LOCAL_SANDBOX_PORT = 2468; const dockerClient = new Dockerode({ socketPath: "/var/run/docker.sock" }); @@ -203,7 +212,7 @@ const baseTaskSandbox = sandboxActor({ if (sandboxProviderId === "e2b") { return e2b({ create: () => ({ - template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x", + template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.5.x", envs: sandboxEnvObject(), // TEMPORARY: Default E2B timeout is 5 minutes which is too short. // Set to 1 hour as a stopgap. Remove this once the E2B provider in @@ -260,7 +269,7 @@ async function providerForConnection(c: any): Promise { sandboxProviderId === "e2b" ? e2b({ create: () => ({ - template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x", + template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.5.x", envs: sandboxEnvObject(), }), installAgents: ["claude", "codex"], @@ -293,36 +302,202 @@ async function listWorkspaceModelGroupsForSandbox(c: any): Promise Promise>; +// --------------------------------------------------------------------------- +// Dynamic repo CWD resolution +// --------------------------------------------------------------------------- + +let cachedRepoCwd: string | null = null; + +/** + * Resolve the repo CWD inside the sandbox by querying `$HOME`. + * Different providers run as different users (E2B: `/home/user`, local Docker: + * `/home/sandbox`), so the path must be resolved dynamically. The result is + * cached for the lifetime of this sandbox actor instance. + */ +async function resolveRepoCwd(c: any): Promise { + if (cachedRepoCwd) return cachedRepoCwd; + + try { + const result = await baseActions.runProcess(c, { + command: "bash", + args: ["-lc", "echo $HOME"], + cwd: "/", + timeoutMs: 10_000, + }); + const home = (result.stdout ?? result.result ?? "").trim(); + if (home && home.startsWith("/")) { + cachedRepoCwd = `${home}/repo`; + return cachedRepoCwd; + } + } catch (error) { + logActorWarning("taskSandbox", "failed to resolve $HOME, using default", { + error: resolveErrorMessage(error), + }); + } + + cachedRepoCwd = DEFAULT_SANDBOX_REPO_CWD; + return cachedRepoCwd; +} + +// --------------------------------------------------------------------------- +// Queue names for sandbox actor +// --------------------------------------------------------------------------- + +const SANDBOX_QUEUE_NAMES = [ + "sandbox.command.createSession", + "sandbox.command.resumeOrCreateSession", + "sandbox.command.destroySession", + "sandbox.command.createProcess", + "sandbox.command.stopProcess", + "sandbox.command.killProcess", + "sandbox.command.deleteProcess", +] as const; + +type SandboxQueueName = (typeof SANDBOX_QUEUE_NAMES)[number]; + +function sandboxWorkflowQueueName(name: SandboxQueueName): SandboxQueueName { + return name; +} + +// --------------------------------------------------------------------------- +// Mutation handlers — executed inside the workflow command loop +// --------------------------------------------------------------------------- + +async function createSessionMutation(c: any, request: any): Promise { + const session = await baseActions.createSession(c, request); + const sessionId = typeof request?.id === "string" && request.id.length > 0 ? request.id : session?.id; + const modeId = modeIdForAgent(request?.agent); + if (sessionId && modeId) { + try { + await baseActions.rawSendSessionMethod(c, sessionId, "session/set_mode", { modeId }); + } catch { + // Session mode updates are best-effort. + } + } + return sanitizeActorResult(session); +} + +async function resumeOrCreateSessionMutation(c: any, request: any): Promise { + return sanitizeActorResult(await baseActions.resumeOrCreateSession(c, request)); +} + +async function destroySessionMutation(c: any, sessionId: string): Promise { + return sanitizeActorResult(await baseActions.destroySession(c, sessionId)); +} + +async function createProcessMutation(c: any, request: any): Promise { + const created = await baseActions.createProcess(c, request); + await broadcastProcesses(c, baseActions); + return created; +} + +async function runProcessMutation(c: any, request: any): Promise { + const result = await baseActions.runProcess(c, request); + await broadcastProcesses(c, baseActions); + return result; +} + +async function stopProcessMutation(c: any, processId: string, query?: any): Promise { + const stopped = await baseActions.stopProcess(c, processId, query); + await broadcastProcesses(c, baseActions); + return stopped; +} + +async function killProcessMutation(c: any, processId: string, query?: any): Promise { + const killed = await baseActions.killProcess(c, processId, query); + await broadcastProcesses(c, baseActions); + return killed; +} + +async function deleteProcessMutation(c: any, processId: string): Promise { + await baseActions.deleteProcess(c, processId); + await broadcastProcesses(c, baseActions); +} + +// --------------------------------------------------------------------------- +// Workflow command loop +// --------------------------------------------------------------------------- + +type SandboxWorkflowHandler = (loopCtx: any, body: any) => Promise; + +const SANDBOX_COMMAND_HANDLERS: Record = { + "sandbox.command.createSession": async (c, body) => createSessionMutation(c, body), + "sandbox.command.resumeOrCreateSession": async (c, body) => resumeOrCreateSessionMutation(c, body), + "sandbox.command.destroySession": async (c, body) => destroySessionMutation(c, body?.sessionId), + "sandbox.command.createProcess": async (c, body) => createProcessMutation(c, body), + "sandbox.command.stopProcess": async (c, body) => stopProcessMutation(c, body?.processId, body?.query), + "sandbox.command.killProcess": async (c, body) => killProcessMutation(c, body?.processId, body?.query), + "sandbox.command.deleteProcess": async (c, body) => { + await deleteProcessMutation(c, body?.processId); + return { ok: true }; + }, +}; + +async function runSandboxWorkflow(ctx: any): Promise { + await ctx.loop("sandbox-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-sandbox-command", { + names: [...SANDBOX_QUEUE_NAMES], + completable: true, + }); + + if (!msg) { + return Loop.continue(undefined); + } + + const handler = SANDBOX_COMMAND_HANDLERS[msg.name as SandboxQueueName]; + if (!handler) { + logActorWarning("taskSandbox", "unknown sandbox command", { command: msg.name }); + await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {}); + return Loop.continue(undefined); + } + + try { + // Wrap in a step so c.state and c.db are accessible inside mutation functions. + const result = await loopCtx.step({ + name: msg.name, + timeout: 10 * 60_000, + run: async () => handler(loopCtx, msg.body), + }); + try { + await msg.complete(result); + } catch (completeError) { + logActorWarning("taskSandbox", "sandbox workflow failed completing response", { + command: msg.name, + error: resolveErrorMessage(completeError), + }); + } + } catch (error) { + const message = resolveErrorMessage(error); + logActorWarning("taskSandbox", "sandbox workflow command failed", { + command: msg.name, + error: message, + }); + await msg.complete({ error: message }).catch(() => {}); + } + + return Loop.continue(undefined); + }); +} + +// --------------------------------------------------------------------------- +// Actor definition +// --------------------------------------------------------------------------- + export const taskSandbox = actor({ ...baseTaskSandbox.config, + queues: Object.fromEntries(SANDBOX_QUEUE_NAMES.map((name) => [name, queue()])), options: { ...baseTaskSandbox.config.options, actionTimeout: 10 * 60_000, }, actions: { ...baseActions, - async createSession(c: any, request: any): Promise { - const session = await baseActions.createSession(c, request); - const sessionId = typeof request?.id === "string" && request.id.length > 0 ? request.id : session?.id; - const modeId = modeIdForAgent(request?.agent); - if (sessionId && modeId) { - try { - await baseActions.rawSendSessionMethod(c, sessionId, "session/set_mode", { modeId }); - } catch { - // Session mode updates are best-effort. - } - } - return sanitizeActorResult(session); - }, + // Read actions — direct (no queue) async resumeSession(c: any, sessionId: string): Promise { return sanitizeActorResult(await baseActions.resumeSession(c, sessionId)); }, - async resumeOrCreateSession(c: any, request: any): Promise { - return sanitizeActorResult(await baseActions.resumeOrCreateSession(c, request)); - }, - async getSession(c: any, sessionId: string): Promise { return sanitizeActorResult(await baseActions.getSession(c, sessionId)); }, @@ -331,24 +506,6 @@ export const taskSandbox = actor({ return sanitizeActorResult(await baseActions.listSessions(c, query)); }, - async destroySession(c: any, sessionId: string): Promise { - return sanitizeActorResult(await baseActions.destroySession(c, sessionId)); - }, - - async sendPrompt(c: any, request: { sessionId: string; prompt: string }): Promise { - const text = typeof request?.prompt === "string" ? request.prompt.trim() : ""; - if (!text) { - return null; - } - - const session = await baseActions.resumeSession(c, request.sessionId); - if (!session || typeof session.prompt !== "function") { - throw new Error(`session '${request.sessionId}' not found`); - } - - return sanitizeActorResult(await session.prompt([{ type: "text", text }])); - }, - async listProcesses(c: any): Promise { try { return await baseActions.listProcesses(c); @@ -362,35 +519,6 @@ export const taskSandbox = actor({ } }, - async createProcess(c: any, request: any): Promise { - const created = await baseActions.createProcess(c, request); - await broadcastProcesses(c, baseActions); - return created; - }, - - async runProcess(c: any, request: any): Promise { - const result = await baseActions.runProcess(c, request); - await broadcastProcesses(c, baseActions); - return result; - }, - - async stopProcess(c: any, processId: string, query?: any): Promise { - const stopped = await baseActions.stopProcess(c, processId, query); - await broadcastProcesses(c, baseActions); - return stopped; - }, - - async killProcess(c: any, processId: string, query?: any): Promise { - const killed = await baseActions.killProcess(c, processId, query); - await broadcastProcesses(c, baseActions); - return killed; - }, - - async deleteProcess(c: any, processId: string): Promise { - await baseActions.deleteProcess(c, processId); - await broadcastProcesses(c, baseActions); - }, - async sandboxAgentConnection(c: any): Promise<{ endpoint: string; token?: string }> { const provider = await providerForConnection(c); if (!provider || !c.state.sandboxId) { @@ -442,10 +570,77 @@ export const taskSandbox = actor({ } }, - async repoCwd(): Promise<{ cwd: string }> { - return { cwd: SANDBOX_REPO_CWD }; + async repoCwd(c: any): Promise<{ cwd: string }> { + const resolved = await resolveRepoCwd(c); + return { cwd: resolved }; + }, + + // Long-running action — kept as direct action to avoid blocking the + // workflow loop (prompt responses can take minutes). + async sendPrompt(c: any, request: { sessionId: string; prompt: string }): Promise { + const text = typeof request?.prompt === "string" ? request.prompt.trim() : ""; + if (!text) { + return null; + } + + const session = await baseActions.resumeSession(c, request.sessionId); + if (!session || typeof session.prompt !== "function") { + throw new Error(`session '${request.sessionId}' not found`); + } + + return sanitizeActorResult(await session.prompt([{ type: "text", text }])); + }, + + // Mutation actions — self-send to queue for workflow history + async createSession(c: any, request: any): Promise { + const self = selfTaskSandbox(c); + return expectQueueResponse(await self.send(sandboxWorkflowQueueName("sandbox.command.createSession"), request ?? {}, { wait: true, timeout: 10_000 })); + }, + + async resumeOrCreateSession(c: any, request: any): Promise { + const self = selfTaskSandbox(c); + return expectQueueResponse( + await self.send(sandboxWorkflowQueueName("sandbox.command.resumeOrCreateSession"), request ?? {}, { wait: true, timeout: 10_000 }), + ); + }, + + async destroySession(c: any, sessionId: string): Promise { + const self = selfTaskSandbox(c); + return expectQueueResponse(await self.send(sandboxWorkflowQueueName("sandbox.command.destroySession"), { sessionId }, { wait: true, timeout: 10_000 })); + }, + + async createProcess(c: any, request: any): Promise { + const self = selfTaskSandbox(c); + return expectQueueResponse(await self.send(sandboxWorkflowQueueName("sandbox.command.createProcess"), request ?? {}, { wait: true, timeout: 10_000 })); + }, + + // runProcess kept as direct action — response can exceed 128KB queue limit + async runProcess(c: any, request: any): Promise { + const result = await baseActions.runProcess(c, request); + await broadcastProcesses(c, baseActions); + return result; + }, + + async stopProcess(c: any, processId: string, query?: any): Promise { + const self = selfTaskSandbox(c); + return expectQueueResponse( + await self.send(sandboxWorkflowQueueName("sandbox.command.stopProcess"), { processId, query }, { wait: true, timeout: 10_000 }), + ); + }, + + async killProcess(c: any, processId: string, query?: any): Promise { + const self = selfTaskSandbox(c); + return expectQueueResponse( + await self.send(sandboxWorkflowQueueName("sandbox.command.killProcess"), { processId, query }, { wait: true, timeout: 10_000 }), + ); + }, + + async deleteProcess(c: any, processId: string): Promise { + const self = selfTaskSandbox(c); + await self.send(sandboxWorkflowQueueName("sandbox.command.deleteProcess"), { processId }, { wait: false }); }, }, + run: workflow(runSandboxWorkflow), }); -export { SANDBOX_REPO_CWD }; +export { DEFAULT_SANDBOX_REPO_CWD, resolveRepoCwd }; diff --git a/foundry/packages/backend/src/actors/task/db/migrations.ts b/foundry/packages/backend/src/actors/task/db/migrations.ts index 1e6ff76..61b0dff 100644 --- a/foundry/packages/backend/src/actors/task/db/migrations.ts +++ b/foundry/packages/backend/src/actors/task/db/migrations.ts @@ -10,6 +10,12 @@ const journal = { tag: "0000_charming_maestro", breakpoints: true, }, + { + idx: 1, + when: 1773984000000, + tag: "0001_add_task_owner", + breakpoints: true, + }, ], } as const; @@ -65,6 +71,16 @@ CREATE TABLE \`task_workspace_sessions\` ( \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL ); +`, + m0001: `CREATE TABLE \`task_owner\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`primary_user_id\` text, + \`primary_github_login\` text, + \`primary_github_email\` text, + \`primary_github_avatar_url\` text, + \`updated_at\` integer NOT NULL, + CONSTRAINT "task_owner_singleton_id_check" CHECK("task_owner"."id" = 1) +); `, } as const, }; diff --git a/foundry/packages/backend/src/actors/task/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts index 651ff76..bdb7cf7 100644 --- a/foundry/packages/backend/src/actors/task/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -47,6 +47,24 @@ export const taskSandboxes = sqliteTable("task_sandboxes", { updatedAt: integer("updated_at").notNull(), }); +/** + * Single-row table tracking the primary user (owner) of this task. + * The owner's GitHub OAuth credentials are injected into the sandbox + * for git operations. Updated when a different user sends a message. + */ +export const taskOwner = sqliteTable( + "task_owner", + { + id: integer("id").primaryKey(), + primaryUserId: text("primary_user_id"), + primaryGithubLogin: text("primary_github_login"), + primaryGithubEmail: text("primary_github_email"), + primaryGithubAvatarUrl: text("primary_github_avatar_url"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("task_owner_singleton_id_check", sql`${table.id} = 1`)], +); + /** * Coordinator index of workspace sessions within this task. * The task actor is the coordinator for sessions. Each row holds session diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts index 7e1c5e2..68bee1c 100644 --- a/foundry/packages/backend/src/actors/task/index.ts +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -1,9 +1,26 @@ -import { actor } from "rivetkit"; +import { actor, queue } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; import type { TaskRecord } from "@sandbox-agent/foundry-shared"; import { taskDb } from "./db/db.js"; import { getCurrentRecord } from "./workflow/common.js"; -import { getSessionDetail, getTaskDetail, getTaskSummary } from "./workspace.js"; -import { taskCommandActions } from "./workflow/index.js"; +import { + changeWorkspaceModel, + getSessionDetail, + getTaskDetail, + getTaskSummary, + markWorkspaceUnread, + refreshWorkspaceDerivedState, + refreshWorkspaceSessionTranscript, + renameWorkspaceSession, + renameWorkspaceTask, + selectWorkspaceSession, + setWorkspaceSessionUnread, + syncTaskPullRequest, + syncWorkspaceSessionStatus, + updateWorkspaceDraft, +} from "./workspace.js"; +import { runTaskWorkflow } from "./workflow/index.js"; +import { TASK_QUEUE_NAMES } from "./workflow/queue.js"; export interface TaskInput { organizationId: string; @@ -13,6 +30,7 @@ export interface TaskInput { export const task = actor({ db: taskDb, + queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])), options: { name: "Task", icon: "wrench", @@ -40,8 +58,42 @@ export const task = actor({ return await getSessionDetail(c, input.sessionId, input.authSessionId); }, - ...taskCommandActions, + // Direct actions migrated from queue: + async markUnread(c, input: { authSessionId?: string }) { + await markWorkspaceUnread(c, input?.authSessionId); + }, + async renameTask(c, input: { value: string }) { + await renameWorkspaceTask(c, input.value); + }, + async renameSession(c, input: { sessionId: string; title: string }) { + await renameWorkspaceSession(c, input.sessionId, input.title); + }, + async selectSession(c, input: { sessionId: string; authSessionId?: string }) { + await selectWorkspaceSession(c, input.sessionId, input?.authSessionId); + }, + async setSessionUnread(c, input: { sessionId: string; unread: boolean; authSessionId?: string }) { + await setWorkspaceSessionUnread(c, input.sessionId, input.unread, input?.authSessionId); + }, + async updateDraft(c, input: { sessionId: string; text: string; attachments: any[]; authSessionId?: string }) { + await updateWorkspaceDraft(c, input.sessionId, input.text, input.attachments, input?.authSessionId); + }, + async changeModel(c, input: { sessionId: string; model: string; authSessionId?: string }) { + await changeWorkspaceModel(c, input.sessionId, input.model, input?.authSessionId); + }, + async refreshSessionTranscript(c, input: { sessionId: string }) { + await refreshWorkspaceSessionTranscript(c, input.sessionId); + }, + async refreshDerived(c) { + await refreshWorkspaceDerivedState(c); + }, + async syncSessionStatus(c, input: { sessionId: string; status: "running" | "idle" | "error"; at: number }) { + await syncWorkspaceSessionStatus(c, input.sessionId, input.status, input.at); + }, + async syncPullRequest(c, input: { pullRequest: any }) { + await syncTaskPullRequest(c, input?.pullRequest ?? null); + }, }, + run: workflow(runTaskWorkflow), }); export { taskWorkflowQueueName } from "./workflow/index.js"; diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index 69004ee..75b2da3 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -1,10 +1,21 @@ +// @ts-nocheck +/** + * Task workflow — queue-based command loop. + * + * Mutations are dispatched through named queues and processed inside the + * workflow command loop so that every command appears in the RivetKit + * inspector's workflow history. Read actions remain direct (no queue). + * + * Callers send commands directly via `.send(taskWorkflowQueueName(...), ...)`. + */ +import { Loop } from "rivetkit/workflow"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; +import { TASK_QUEUE_NAMES, type TaskQueueName, taskWorkflowQueueName } from "./queue.js"; import { getCurrentRecord } from "./common.js"; import { initBootstrapDbActivity, initCompleteActivity, initEnqueueProvisionActivity, initFailedActivity } from "./init.js"; import { handleArchiveActivity, handleAttachActivity, - handleGetActivity, handlePushActivity, handleSimpleCommandActivity, handleSwitchActivity, @@ -12,253 +23,163 @@ import { killWriteDbActivity, } from "./commands.js"; import { - changeWorkspaceModel, + changeTaskOwnerManually, closeWorkspaceSession, createWorkspaceSession, ensureWorkspaceSession, - refreshWorkspaceDerivedState, - refreshWorkspaceSessionTranscript, - markWorkspaceUnread, publishWorkspacePr, - renameWorkspaceTask, - renameWorkspaceSession, - selectWorkspaceSession, revertWorkspaceFile, sendWorkspaceMessage, - setWorkspaceSessionUnread, stopWorkspaceSession, - syncTaskPullRequest, - syncWorkspaceSessionStatus, - updateWorkspaceDraft, } from "../workspace.js"; export { taskWorkflowQueueName } from "./queue.js"; -/** - * Task command actions — converted from queue/workflow handlers to direct actions. - * Each export becomes an action on the task actor. - */ -export const taskCommandActions = { - async initialize(c: any, body: any) { - await initBootstrapDbActivity(c, body); - await initEnqueueProvisionActivity(c, body); - return await getCurrentRecord(c); +// --------------------------------------------------------------------------- +// Workflow command loop — runs inside `run: workflow(runTaskWorkflow)` +// --------------------------------------------------------------------------- + +type WorkflowHandler = (loopCtx: any, msg: any) => Promise; + +const COMMAND_HANDLERS: Record = { + "task.command.initialize": async (loopCtx, msg) => { + await initBootstrapDbActivity(loopCtx, msg.body); + await initEnqueueProvisionActivity(loopCtx, msg.body); + const record = await getCurrentRecord(loopCtx); + await msg.complete(record); }, - async provision(c: any, body: any) { + "task.command.provision": async (loopCtx, msg) => { try { - await initCompleteActivity(c, body); - return { ok: true }; + await initCompleteActivity(loopCtx, msg.body); + await msg.complete({ ok: true }); } catch (error) { - await initFailedActivity(c, error, body); - return { ok: false, error: resolveErrorMessage(error) }; + await initFailedActivity(loopCtx, error, msg.body); + await msg.complete({ ok: false, error: resolveErrorMessage(error) }); } }, - async attach(c: any, body: any) { - // handleAttachActivity expects msg with complete — adapt - const result = { value: undefined as any }; - const msg = { - name: "task.command.attach", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handleAttachActivity(c, msg); - return result.value; + "task.command.attach": async (loopCtx, msg) => { + await handleAttachActivity(loopCtx, msg); }, - async switchTask(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.switch", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handleSwitchActivity(c, msg); - return result.value; + "task.command.switch": async (loopCtx, msg) => { + await handleSwitchActivity(loopCtx, msg); }, - async push(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.push", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handlePushActivity(c, msg); - return result.value; + "task.command.push": async (loopCtx, msg) => { + await handlePushActivity(loopCtx, msg); }, - async sync(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.sync", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handleSimpleCommandActivity(c, msg, "task.sync"); - return result.value; + "task.command.sync": async (loopCtx, msg) => { + await handleSimpleCommandActivity(loopCtx, msg, "task.sync"); }, - async merge(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.merge", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handleSimpleCommandActivity(c, msg, "task.merge"); - return result.value; + "task.command.merge": async (loopCtx, msg) => { + await handleSimpleCommandActivity(loopCtx, msg, "task.merge"); }, - async archive(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.archive", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handleArchiveActivity(c, msg); - return result.value; + "task.command.archive": async (loopCtx, msg) => { + await handleArchiveActivity(loopCtx, msg); }, - async kill(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.kill", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await killDestroySandboxActivity(c); - await killWriteDbActivity(c, msg); - return result.value; + "task.command.kill": async (loopCtx, msg) => { + await killDestroySandboxActivity(loopCtx); + await killWriteDbActivity(loopCtx, msg); }, - async getRecord(c: any, body: any) { - const result = { value: undefined as any }; - const msg = { - name: "task.command.get", - body, - complete: async (v: any) => { - result.value = v; - }, - }; - await handleGetActivity(c, msg); - return result.value; + "task.command.workspace.create_session": async (loopCtx, msg) => { + const result = await createWorkspaceSession(loopCtx, msg.body?.model, msg.body?.authSessionId); + await msg.complete(result); }, - async pullRequestSync(c: any, body: any) { - await syncTaskPullRequest(c, body?.pullRequest ?? null); - return { ok: true }; - }, - - async markUnread(c: any, body: any) { - await markWorkspaceUnread(c, body?.authSessionId); - return { ok: true }; - }, - - async renameTask(c: any, body: any) { - await renameWorkspaceTask(c, body.value); - return { ok: true }; - }, - - async createSession(c: any, body: any) { - return await createWorkspaceSession(c, body?.model, body?.authSessionId); - }, - - async createSessionAndSend(c: any, body: any) { + "task.command.workspace.create_session_and_send": async (loopCtx, msg) => { try { - const created = await createWorkspaceSession(c, body?.model, body?.authSessionId); - await sendWorkspaceMessage(c, created.sessionId, body.text, [], body?.authSessionId); + const created = await createWorkspaceSession(loopCtx, msg.body?.model, msg.body?.authSessionId); + await sendWorkspaceMessage(loopCtx, created.sessionId, msg.body.text, [], msg.body?.authSessionId); } catch (error) { logActorWarning("task.workflow", "create_session_and_send failed", { error: resolveErrorMessage(error), }); } - return { ok: true }; + await msg.complete({ ok: true }); }, - async ensureSession(c: any, body: any) { - await ensureWorkspaceSession(c, body.sessionId, body?.model, body?.authSessionId); - return { ok: true }; + "task.command.workspace.ensure_session": async (loopCtx, msg) => { + await ensureWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.model, msg.body?.authSessionId); + await msg.complete({ ok: true }); }, - async renameSession(c: any, body: any) { - await renameWorkspaceSession(c, body.sessionId, body.title); - return { ok: true }; + "task.command.workspace.send_message": async (loopCtx, msg) => { + await sendWorkspaceMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId); + await msg.complete({ ok: true }); }, - async selectSession(c: any, body: any) { - await selectWorkspaceSession(c, body.sessionId, body?.authSessionId); - return { ok: true }; + "task.command.workspace.stop_session": async (loopCtx, msg) => { + await stopWorkspaceSession(loopCtx, msg.body.sessionId); + await msg.complete({ ok: true }); }, - async setSessionUnread(c: any, body: any) { - await setWorkspaceSessionUnread(c, body.sessionId, body.unread, body?.authSessionId); - return { ok: true }; + "task.command.workspace.close_session": async (loopCtx, msg) => { + await closeWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.authSessionId); + await msg.complete({ ok: true }); }, - async updateDraft(c: any, body: any) { - await updateWorkspaceDraft(c, body.sessionId, body.text, body.attachments, body?.authSessionId); - return { ok: true }; + "task.command.workspace.publish_pr": async (loopCtx, msg) => { + await publishWorkspacePr(loopCtx); + await msg.complete({ ok: true }); }, - async changeModel(c: any, body: any) { - await changeWorkspaceModel(c, body.sessionId, body.model, body?.authSessionId); - return { ok: true }; + "task.command.workspace.revert_file": async (loopCtx, msg) => { + await revertWorkspaceFile(loopCtx, msg.body.path); + await msg.complete({ ok: true }); }, - async sendMessage(c: any, body: any) { - await sendWorkspaceMessage(c, body.sessionId, body.text, body.attachments, body?.authSessionId); - return { ok: true }; - }, - - async stopSession(c: any, body: any) { - await stopWorkspaceSession(c, body.sessionId); - return { ok: true }; - }, - - async syncSessionStatus(c: any, body: any) { - await syncWorkspaceSessionStatus(c, body.sessionId, body.status, body.at); - return { ok: true }; - }, - - async refreshDerived(c: any, _body: any) { - await refreshWorkspaceDerivedState(c); - return { ok: true }; - }, - - async refreshSessionTranscript(c: any, body: any) { - await refreshWorkspaceSessionTranscript(c, body.sessionId); - return { ok: true }; - }, - - async closeSession(c: any, body: any) { - await closeWorkspaceSession(c, body.sessionId, body?.authSessionId); - return { ok: true }; - }, - - async publishPr(c: any, _body: any) { - await publishWorkspacePr(c); - return { ok: true }; - }, - - async revertFile(c: any, body: any) { - await revertWorkspaceFile(c, body.path); - return { ok: true }; + "task.command.workspace.change_owner": async (loopCtx, msg) => { + await changeTaskOwnerManually(loopCtx, { + primaryUserId: msg.body.primaryUserId, + primaryGithubLogin: msg.body.primaryGithubLogin, + primaryGithubEmail: msg.body.primaryGithubEmail, + primaryGithubAvatarUrl: msg.body.primaryGithubAvatarUrl ?? null, + }); + await msg.complete({ ok: true }); }, }; + +export async function runTaskWorkflow(ctx: any): Promise { + await ctx.loop("task-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-task-command", { + names: [...TASK_QUEUE_NAMES], + completable: true, + }); + + if (!msg) { + return Loop.continue(undefined); + } + + const handler = COMMAND_HANDLERS[msg.name as TaskQueueName]; + if (!handler) { + logActorWarning("task.workflow", "unknown task command", { command: msg.name }); + await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {}); + return Loop.continue(undefined); + } + + try { + // Wrap in a step so c.state and c.db are accessible inside mutation functions. + await loopCtx.step({ + name: msg.name, + timeout: 10 * 60_000, + run: async () => handler(loopCtx, msg), + }); + } catch (error) { + const message = resolveErrorMessage(error); + logActorWarning("task.workflow", "task workflow command failed", { + command: msg.name, + error: message, + }); + await msg.complete({ error: message }).catch(() => {}); + } + + return Loop.continue(undefined); + }); +} diff --git a/foundry/packages/backend/src/actors/task/workflow/init.ts b/foundry/packages/backend/src/actors/task/workflow/init.ts index 08085e8..ffdf1d4 100644 --- a/foundry/packages/backend/src/actors/task/workflow/init.ts +++ b/foundry/packages/backend/src/actors/task/workflow/init.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../../context.js"; import { selfTask } from "../../handles.js"; import { resolveErrorMessage } from "../../logging.js"; +import { taskWorkflowQueueName } from "./queue.js"; import { defaultSandboxProviderId } from "../../../sandbox-config.js"; import { task as taskTable, taskRuntime } from "../db/schema.js"; import { TASK_ROW_ID, appendAuditLog, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js"; @@ -72,7 +73,7 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro const self = selfTask(loopCtx); try { - void self.provision(body).catch(() => {}); + void self.send(taskWorkflowQueueName("task.command.provision"), body ?? {}, { wait: false }).catch(() => {}); } catch (error) { logActorWarning("task.init", "background provision command failed", { organizationId: loopCtx.state.organizationId, diff --git a/foundry/packages/backend/src/actors/task/workflow/queue.ts b/foundry/packages/backend/src/actors/task/workflow/queue.ts index 133a657..a49c39a 100644 --- a/foundry/packages/backend/src/actors/task/workflow/queue.ts +++ b/foundry/packages/backend/src/actors/task/workflow/queue.ts @@ -8,28 +8,19 @@ export const TASK_QUEUE_NAMES = [ "task.command.merge", "task.command.archive", "task.command.kill", - "task.command.get", - "task.command.pull_request.sync", - "task.command.workspace.mark_unread", - "task.command.workspace.rename_task", "task.command.workspace.create_session", "task.command.workspace.create_session_and_send", "task.command.workspace.ensure_session", - "task.command.workspace.rename_session", - "task.command.workspace.select_session", - "task.command.workspace.set_session_unread", - "task.command.workspace.update_draft", - "task.command.workspace.change_model", "task.command.workspace.send_message", "task.command.workspace.stop_session", - "task.command.workspace.sync_session_status", - "task.command.workspace.refresh_derived", - "task.command.workspace.refresh_session_transcript", "task.command.workspace.close_session", "task.command.workspace.publish_pr", "task.command.workspace.revert_file", + "task.command.workspace.change_owner", ] as const; +export type TaskQueueName = (typeof TASK_QUEUE_NAMES)[number]; + export function taskWorkflowQueueName(name: string): string { return name; } diff --git a/foundry/packages/backend/src/actors/task/workspace.ts b/foundry/packages/backend/src/actors/task/workspace.ts index 7505d01..0856947 100644 --- a/foundry/packages/backend/src/actors/task/workspace.ts +++ b/foundry/packages/backend/src/actors/task/workspace.ts @@ -1,6 +1,6 @@ // @ts-nocheck import { randomUUID } from "node:crypto"; -import { basename, dirname } from "node:path"; +import { basename } from "node:path"; import { asc, eq } from "drizzle-orm"; import { DEFAULT_WORKSPACE_MODEL_GROUPS, @@ -10,16 +10,15 @@ import { } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; import { getOrCreateOrganization, getOrCreateTaskSandbox, getOrCreateUser, getTaskSandbox, selfTask } from "../handles.js"; -import { logActorWarning, resolveErrorMessage } from "../logging.js"; -import { SANDBOX_REPO_CWD } from "../sandbox/index.js"; +import { logActorInfo, logActorWarning, resolveErrorMessage } from "../logging.js"; import { resolveSandboxProviderId } from "../../sandbox-config.js"; import { getBetterAuthService } from "../../services/better-auth.js"; -// expectQueueResponse removed — actions return values directly import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { githubRepoFullNameFromRemote } from "../../services/repo.js"; -// organization actions called directly (no queue) +import { taskWorkflowQueueName } from "./workflow/queue.js"; +import { organizationWorkflowQueueName } from "../organization/queues.js"; -import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; +import { task as taskTable, taskOwner, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; function emptyGitState() { @@ -123,6 +122,191 @@ function parseGitState(value: string | null | undefined): { fileChanges: Array { + const row = await c.db.select().from(taskOwner).where(eq(taskOwner.id, 1)).get(); + if (!row) { + return null; + } + return { + primaryUserId: row.primaryUserId ?? null, + primaryGithubLogin: row.primaryGithubLogin ?? null, + primaryGithubEmail: row.primaryGithubEmail ?? null, + primaryGithubAvatarUrl: row.primaryGithubAvatarUrl ?? null, + }; +} + +async function upsertTaskOwner( + c: any, + owner: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + const now = Date.now(); + await c.db + .insert(taskOwner) + .values({ + id: 1, + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: taskOwner.id, + set: { + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }, + }) + .run(); +} + +/** + * Inject the user's GitHub OAuth token into the sandbox as a git credential store file. + * Also configures git user.name and user.email so commits are attributed correctly. + * The credential file is overwritten on each owner swap. + * + * Race condition note: If User A sends a message and the agent starts a long git operation, + * then User B triggers an owner swap, the in-flight git process still has User A's credentials + * (already read from the credential store). The next git operation uses User B's credentials. + */ +async function injectGitCredentials(sandbox: any, login: string, email: string, token: string): Promise { + const script = [ + "set -euo pipefail", + `git config --global user.name ${JSON.stringify(login)}`, + `git config --global user.email ${JSON.stringify(email)}`, + `git config --global credential.helper 'store --file=$HOME/.git-token'`, + `printf '%s\\n' ${JSON.stringify(`https://${login}:${token}@github.com`)} > $HOME/.git-token`, + `chmod 600 $HOME/.git-token`, + ]; + const result = await sandbox.runProcess({ + command: "bash", + args: ["-lc", script.join("; ")], + cwd: "/", + timeoutMs: 30_000, + }); + if ((result.exitCode ?? 0) !== 0) { + logActorWarning("task", "git credential injection failed", { + exitCode: result.exitCode, + output: [result.stdout, result.stderr].filter(Boolean).join(""), + }); + } +} + +/** + * Resolves the current user's GitHub identity from their auth session. + * Returns null if the session is invalid or the user has no GitHub account. + */ +async function resolveGithubIdentity(authSessionId: string): Promise<{ + userId: string; + login: string; + email: string; + avatarUrl: string | null; + accessToken: string; +} | null> { + const authService = getBetterAuthService(); + const authState = await authService.getAuthState(authSessionId); + if (!authState?.user?.id) { + return null; + } + + const tokenResult = await authService.getAccessTokenForSession(authSessionId); + if (!tokenResult?.accessToken) { + return null; + } + + const githubAccount = authState.accounts?.find((account: any) => account.providerId === "github"); + if (!githubAccount) { + return null; + } + + // Resolve the GitHub login from the API since Better Auth only stores the + // numeric account ID, not the login username. + let login = authState.user.name ?? "unknown"; + let avatarUrl = authState.user.image ?? null; + try { + const resp = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenResult.accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + if (resp.ok) { + const ghUser = (await resp.json()) as { login?: string; avatar_url?: string }; + if (ghUser.login) { + login = ghUser.login; + } + if (ghUser.avatar_url) { + avatarUrl = ghUser.avatar_url; + } + } + } catch (error) { + console.warn("resolveGithubIdentity: failed to fetch GitHub user", error); + } + + return { + userId: authState.user.id, + login, + email: authState.user.email ?? `${githubAccount.accountId}@users.noreply.github.com`, + avatarUrl, + accessToken: tokenResult.accessToken, + }; +} + +/** + * Check if the task owner needs to swap, and if so, update the owner record + * and inject new git credentials into the sandbox. + * Returns true if an owner swap occurred. + */ +async function maybeSwapTaskOwner(c: any, authSessionId: string | null | undefined, sandbox: any | null): Promise { + if (!authSessionId) { + return false; + } + + const identity = await resolveGithubIdentity(authSessionId); + if (!identity) { + return false; + } + + const currentOwner = await readTaskOwner(c); + if (currentOwner?.primaryUserId === identity.userId) { + return false; + } + + await upsertTaskOwner(c, { + primaryUserId: identity.userId, + primaryGithubLogin: identity.login, + primaryGithubEmail: identity.email, + primaryGithubAvatarUrl: identity.avatarUrl, + }); + + if (sandbox) { + await injectGitCredentials(sandbox, identity.login, identity.email, identity.accessToken); + } + + return true; +} + +/** + * Manually change the task owner. Updates the owner record and broadcasts the + * change to subscribers. Git credentials are NOT injected here — they will be + * injected the next time the target user sends a message (auto-swap path). + */ +export async function changeTaskOwnerManually( + c: any, + input: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + await upsertTaskOwner(c, input); + await broadcastTaskUpdate(c); +} + export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { if (status === "running") { return false; @@ -240,7 +424,7 @@ async function upsertUserTaskState(c: any, authSessionId: string | null | undefi } const user = await getOrCreateUser(c, userId); - await user.taskStateUpsert({ + await user.upsertTaskState({ taskId: c.state.taskId, sessionId, patch, @@ -259,7 +443,7 @@ async function deleteUserTaskState(c: any, authSessionId: string | null | undefi } const user = await getOrCreateUser(c, userId); - await user.taskStateDelete({ + await user.deleteTaskState({ taskId: c.state.taskId, sessionId, }); @@ -391,6 +575,10 @@ async function getTaskSandboxRuntime( const sandbox = await getOrCreateTaskSandbox(c, c.state.organizationId, sandboxId, {}); const actorId = typeof sandbox.resolve === "function" ? await sandbox.resolve().catch(() => null) : null; const switchTarget = sandboxProviderId === "local" ? `sandbox://local/${sandboxId}` : `sandbox://e2b/${sandboxId}`; + + // Resolve the actual repo CWD from the sandbox's $HOME (differs by provider). + const repoCwdResult = await sandbox.repoCwd(); + const cwd = repoCwdResult?.cwd ?? "$HOME/repo"; const now = Date.now(); await c.db @@ -400,7 +588,7 @@ async function getTaskSandboxRuntime( sandboxProviderId, sandboxActorId: typeof actorId === "string" ? actorId : null, switchTarget, - cwd: SANDBOX_REPO_CWD, + cwd, createdAt: now, updatedAt: now, }) @@ -410,7 +598,7 @@ async function getTaskSandboxRuntime( sandboxProviderId, sandboxActorId: typeof actorId === "string" ? actorId : null, switchTarget, - cwd: SANDBOX_REPO_CWD, + cwd, updatedAt: now, }, }) @@ -421,7 +609,7 @@ async function getTaskSandboxRuntime( .set({ activeSandboxId: sandboxId, activeSwitchTarget: switchTarget, - activeCwd: SANDBOX_REPO_CWD, + activeCwd: cwd, updatedAt: now, }) .where(eq(taskRuntime.id, 1)) @@ -432,7 +620,7 @@ async function getTaskSandboxRuntime( sandboxId, sandboxProviderId, switchTarget, - cwd: SANDBOX_REPO_CWD, + cwd, }; } @@ -443,7 +631,7 @@ async function getTaskSandboxRuntime( */ let sandboxRepoPrepared = false; -async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean }): Promise { +async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean; authSessionId?: string | null }): Promise { if (!record.branchName) { throw new Error("cannot prepare a sandbox repo before the task branch exists"); } @@ -451,27 +639,35 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski // If the repo was already prepared and the caller allows skipping fetch, just return. // The clone, fetch, and checkout already happened on a prior call. if (opts?.skipFetchIfPrepared && sandboxRepoPrepared) { + logActorInfo("task.sandbox", "ensureSandboxRepo skipped (already prepared)"); return; } + const repoStart = performance.now(); + + const t0 = performance.now(); const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId); const metadata = await getRepositoryMetadata(c); + logActorInfo("task.sandbox", "resolveAuth+metadata", { durationMs: Math.round(performance.now() - t0) }); + const baseRef = metadata.defaultBranch ?? "main"; - const sandboxRepoRoot = dirname(SANDBOX_REPO_CWD); + // Use $HOME inside the shell script so the path resolves correctly regardless + // of which user the sandbox runs as (E2B: "user", local Docker: "sandbox"). const script = [ "set -euo pipefail", - `mkdir -p ${JSON.stringify(sandboxRepoRoot)}`, + 'REPO_DIR="$HOME/repo"', + 'mkdir -p "$HOME"', "git config --global credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'", - `if [ ! -d ${JSON.stringify(`${SANDBOX_REPO_CWD}/.git`)} ]; then rm -rf ${JSON.stringify(SANDBOX_REPO_CWD)} && git clone ${JSON.stringify( - metadata.remoteUrl, - )} ${JSON.stringify(SANDBOX_REPO_CWD)}; fi`, - `cd ${JSON.stringify(SANDBOX_REPO_CWD)}`, + `if [ ! -d "$REPO_DIR/.git" ]; then rm -rf "$REPO_DIR" && git clone ${JSON.stringify(metadata.remoteUrl)} "$REPO_DIR"; fi`, + 'cd "$REPO_DIR"', "git fetch origin --prune", `if git show-ref --verify --quiet refs/remotes/origin/${JSON.stringify(record.branchName).slice(1, -1)}; then target_ref=${JSON.stringify( `origin/${record.branchName}`, )}; else target_ref=${JSON.stringify(baseRef)}; fi`, `git checkout -B ${JSON.stringify(record.branchName)} \"$target_ref\"`, ]; + + const t1 = performance.now(); const result = await sandbox.runProcess({ command: "bash", args: ["-lc", script.join("; ")], @@ -484,12 +680,26 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski : undefined, timeoutMs: 5 * 60_000, }); + logActorInfo("task.sandbox", "git clone/fetch/checkout", { + branch: record.branchName, + repo: metadata.remoteUrl, + durationMs: Math.round(performance.now() - t1), + }); if ((result.exitCode ?? 0) !== 0) { throw new Error(`sandbox repo preparation failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`); } + // On first repo preparation, inject the task owner's git credentials into the sandbox + // so that push/commit operations are authenticated and attributed to the correct user. + if (!sandboxRepoPrepared && opts?.authSessionId) { + const t2 = performance.now(); + await maybeSwapTaskOwner(c, opts.authSessionId, sandbox); + logActorInfo("task.sandbox", "maybeSwapTaskOwner", { durationMs: Math.round(performance.now() - t2) }); + } + sandboxRepoPrepared = true; + logActorInfo("task.sandbox", "ensureSandboxRepo complete", { totalDurationMs: Math.round(performance.now() - repoStart) }); } async function executeInSandbox( @@ -734,22 +944,19 @@ async function writeSessionTranscript(c: any, sessionId: string, transcript: Arr }); } -async function enqueueWorkspaceRefresh( - c: any, - command: "task.command.workspace.refresh_derived" | "task.command.workspace.refresh_session_transcript", - body: Record, -): Promise { - // Call directly since we're inside the task actor (no queue needed) - if (command === "task.command.workspace.refresh_derived") { - void refreshWorkspaceDerivedState(c).catch(() => {}); - } else { - void refreshWorkspaceSessionTranscript(c, body.sessionId as string).catch(() => {}); - } +function fireRefreshDerived(c: any): void { + const self = selfTask(c); + void self.refreshDerived({}).catch(() => {}); +} + +function fireRefreshSessionTranscript(c: any, sessionId: string): void { + const self = selfTask(c); + void self.refreshSessionTranscript({ sessionId }).catch(() => {}); } async function enqueueWorkspaceEnsureSession(c: any, sessionId: string): Promise { - // Call directly since we're inside the task actor - void ensureWorkspaceSession(c, sessionId).catch(() => {}); + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workspace.ensure_session" as any), { sessionId }, { wait: false }); } function pendingWorkspaceSessionStatus(record: any): "pending_provision" | "pending_session_create" { @@ -759,16 +966,14 @@ function pendingWorkspaceSessionStatus(record: any): "pending_provision" | "pend async function maybeScheduleWorkspaceRefreshes(c: any, record: any, sessions: Array): Promise { const gitState = await readCachedGitState(c); if (record.activeSandboxId && !gitState.updatedAt) { - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {}); + fireRefreshDerived(c); } for (const session of sessions) { if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) { continue; } - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { - sessionId: session.sandboxSessionId, - }); + fireRefreshSessionTranscript(c, session.sandboxSessionId); } } @@ -862,6 +1067,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P const activeSessionId = userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null; + const owner = await readTaskOwner(c); + return { id: c.state.taskId, repoId: c.state.repoId, @@ -873,6 +1080,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P pullRequest: record.pullRequest ?? null, activeSessionId, sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))), + primaryUserLogin: owner?.primaryGithubLogin ?? null, + primaryUserAvatarUrl: owner?.primaryGithubAvatarUrl ?? null, }; } @@ -894,11 +1103,28 @@ export async function buildTaskDetail(c: any, authSessionId?: string | null): Pr diffs: gitState.diffs, fileTree: gitState.fileTree, minutesUsed: 0, - sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({ - sandboxProviderId: sandbox.sandboxProviderId, - sandboxId: sandbox.sandboxId, - cwd: sandbox.cwd ?? null, - })), + sandboxes: await Promise.all( + (record.sandboxes ?? []).map(async (sandbox: any) => { + let url: string | null = null; + if (sandbox.sandboxId) { + try { + const handle = getTaskSandbox(c, c.state.organizationId, sandbox.sandboxId); + const conn = await handle.sandboxAgentConnection(); + if (conn?.endpoint && !conn.endpoint.startsWith("mock://")) { + url = conn.endpoint; + } + } catch { + // Sandbox may not be running + } + } + return { + sandboxProviderId: sandbox.sandboxProviderId, + sandboxId: sandbox.sandboxId, + cwd: sandbox.cwd ?? null, + url, + }; + }), + ), activeSandboxId: record.activeSandboxId ?? null, }; } @@ -969,7 +1195,11 @@ export async function getSessionDetail(c: any, sessionId: string, authSessionId? */ export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise { const organization = await getOrCreateOrganization(c, c.state.organizationId); - await organization.commandApplyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) }); + await organization.send( + organizationWorkflowQueueName("organization.command.applyTaskSummaryUpdate"), + { taskSummary: await buildTaskSummary(c) }, + { wait: false }, + ); c.broadcast("taskUpdated", { type: "taskUpdated", detail: await buildTaskDetail(c), @@ -1053,6 +1283,7 @@ export async function createWorkspaceSession(c: any, model?: string, authSession } export async function ensureWorkspaceSession(c: any, sessionId: string, model?: string, authSessionId?: string): Promise { + const ensureStart = performance.now(); const meta = await readSessionMeta(c, sessionId); if (!meta || meta.closed) { return; @@ -1060,9 +1291,7 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?: const record = await ensureWorkspaceSeeded(c); if (meta.sandboxSessionId && meta.status === "ready") { - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { - sessionId: meta.sandboxSessionId, - }); + fireRefreshSessionTranscript(c, meta.sandboxSessionId); await broadcastTaskUpdate(c, { sessionId: sessionId }); return; } @@ -1074,10 +1303,18 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?: }); try { + const t0 = performance.now(); const runtime = await getTaskSandboxRuntime(c, record); + logActorInfo("task.session", "getTaskSandboxRuntime", { sessionId, durationMs: Math.round(performance.now() - t0) }); + + const t1 = performance.now(); await ensureSandboxRepo(c, runtime.sandbox, record); + logActorInfo("task.session", "ensureSandboxRepo", { sessionId, durationMs: Math.round(performance.now() - t1) }); + const resolvedModel = model ?? meta.model ?? (await resolveDefaultModel(c, authSessionId)); const resolvedAgent = await resolveSandboxAgentForModel(c, resolvedModel); + + const t2 = performance.now(); await runtime.sandbox.createSession({ id: meta.sandboxSessionId ?? sessionId, agent: resolvedAgent, @@ -1086,15 +1323,15 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?: cwd: runtime.cwd, }, }); + logActorInfo("task.session", "createSession", { sessionId, agent: resolvedAgent, model: resolvedModel, durationMs: Math.round(performance.now() - t2) }); await updateSessionMeta(c, sessionId, { sandboxSessionId: meta.sandboxSessionId ?? sessionId, status: "ready", errorMessage: null, }); - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { - sessionId: meta.sandboxSessionId ?? sessionId, - }); + logActorInfo("task.session", "ensureWorkspaceSession complete", { sessionId, totalDurationMs: Math.round(performance.now() - ensureStart) }); + fireRefreshSessionTranscript(c, meta.sandboxSessionId ?? sessionId); } catch (error) { await updateSessionMeta(c, sessionId, { status: "error", @@ -1110,8 +1347,9 @@ export async function enqueuePendingWorkspaceSessions(c: any): Promise { (row) => row.closed !== true && row.status !== "ready" && row.status !== "error", ); + const self = selfTask(c); for (const row of pending) { - void ensureWorkspaceSession(c, row.sessionId, row.model).catch(() => {}); + await self.send(taskWorkflowQueueName("task.command.workspace.ensure_session" as any), { sessionId: row.sessionId, model: row.model }, { wait: false }); } } @@ -1207,12 +1445,26 @@ export async function changeWorkspaceModel(c: any, sessionId: string, model: str } export async function sendWorkspaceMessage(c: any, sessionId: string, text: string, attachments: Array, authSessionId?: string): Promise { + const sendStart = performance.now(); const meta = requireSendableSessionMeta(await readSessionMeta(c, sessionId), sessionId); const record = await ensureWorkspaceSeeded(c); + + const t0 = performance.now(); const runtime = await getTaskSandboxRuntime(c, record); + logActorInfo("task.message", "getTaskSandboxRuntime", { sessionId, durationMs: Math.round(performance.now() - t0) }); + + const t1 = performance.now(); // Skip git fetch on subsequent messages — the repo was already prepared during session // creation. This avoids a 5-30s network round-trip to GitHub on every prompt. - await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true }); + await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true, authSessionId }); + logActorInfo("task.message", "ensureSandboxRepo", { sessionId, durationMs: Math.round(performance.now() - t1) }); + + // Check if the task owner needs to swap. If a different user is sending this message, + // update the owner record and inject their git credentials into the sandbox. + const ownerSwapped = await maybeSwapTaskOwner(c, authSessionId, runtime.sandbox); + if (ownerSwapped) { + await broadcastTaskUpdate(c); + } const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)].filter( Boolean, ); @@ -1235,10 +1487,12 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "running", Date.now()); try { + const t2 = performance.now(); await runtime.sandbox.sendPrompt({ sessionId: meta.sandboxSessionId, prompt: prompt.join("\n\n"), }); + logActorInfo("task.message", "sendPrompt", { sessionId, durationMs: Math.round(performance.now() - t2) }); await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "idle", Date.now()); } catch (error) { await updateSessionMeta(c, sessionId, { @@ -1248,6 +1502,7 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "error", Date.now()); throw error; } + logActorInfo("task.message", "sendWorkspaceMessage complete", { sessionId, totalDurationMs: Math.round(performance.now() - sendStart) }); } export async function stopWorkspaceSession(c: any, sessionId: string): Promise { @@ -1291,11 +1546,9 @@ export async function syncWorkspaceSessionStatus(c: any, sessionId: string, stat }) .where(eq(taskTable.id, 1)) .run(); - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { - sessionId, - }); + fireRefreshSessionTranscript(c, sessionId); if (status !== "running") { - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {}); + fireRefreshDerived(c); } await broadcastTaskUpdate(c, { sessionId: meta.sessionId }); } @@ -1393,6 +1646,6 @@ export async function revertWorkspaceFile(c: any, path: string): Promise { if (result.exitCode !== 0) { throw new Error(`file revert failed (${result.exitCode}): ${result.result}`); } - await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {}); + fireRefreshDerived(c); await broadcastTaskUpdate(c); } diff --git a/foundry/packages/backend/src/actors/user/actions/better-auth.ts b/foundry/packages/backend/src/actors/user/actions/better-auth.ts index 0fd950e..3ef8656 100644 --- a/foundry/packages/backend/src/actors/user/actions/better-auth.ts +++ b/foundry/packages/backend/src/actors/user/actions/better-auth.ts @@ -1,9 +1,71 @@ import { asc, count as sqlCount, desc } from "drizzle-orm"; -import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, tableFor } from "../query-helpers.js"; +import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "../query-helpers.js"; +// Exception to the CLAUDE.md queue-for-mutations rule: Better Auth adapter operations +// use direct actions even for mutations. Better Auth runs during OAuth callbacks on the +// HTTP request path, not through the normal organization lifecycle. Routing through the +// queue adds multiple sequential round-trips (each with actor wake-up + step overhead) +// that cause 30-second OAuth callbacks and proxy retry storms. These mutations are simple +// SQLite upserts/deletes with no cross-actor coordination or broadcast side effects. export const betterAuthActions = { - // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. - // Schema and behavior are constrained by Better Auth. + // --- Mutation actions --- + async betterAuthCreateRecord(c, input: { model: string; data: Record }) { + const table = tableFor(input.model); + const persisted = persistInput(input.model, input.data); + await c.db + .insert(table) + .values(persisted as any) + .run(); + const row = await c.db + .select() + .from(table) + .where(buildWhere(table, [{ field: "id", value: input.data.id }])!) + .get(); + return materializeRow(input.model, row); + }, + + async betterAuthUpdateRecord(c, input: { model: string; where: any[]; update: Record }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) throw new Error("betterAuthUpdateRecord requires a where clause"); + await c.db + .update(table) + .set(persistPatch(input.model, input.update) as any) + .where(predicate) + .run(); + return materializeRow(input.model, await c.db.select().from(table).where(predicate).get()); + }, + + async betterAuthUpdateManyRecords(c, input: { model: string; where: any[]; update: Record }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) throw new Error("betterAuthUpdateManyRecords requires a where clause"); + await c.db + .update(table) + .set(persistPatch(input.model, input.update) as any) + .where(predicate) + .run(); + const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get(); + return row?.value ?? 0; + }, + + async betterAuthDeleteRecord(c, input: { model: string; where: any[] }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) throw new Error("betterAuthDeleteRecord requires a where clause"); + await c.db.delete(table).where(predicate).run(); + }, + + async betterAuthDeleteManyRecords(c, input: { model: string; where: any[] }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) throw new Error("betterAuthDeleteManyRecords requires a where clause"); + const rows = await c.db.select().from(table).where(predicate).all(); + await c.db.delete(table).where(predicate).run(); + return rows.length; + }, + + // --- Read actions --- async betterAuthFindOneRecord(c, input: { model: string; where: any[]; join?: any }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -11,8 +73,6 @@ export const betterAuthActions = { return await applyJoinToRow(c, input.model, row ?? null, input.join); }, - // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. - // Schema and behavior are constrained by Better Auth. async betterAuthFindManyRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -34,8 +94,6 @@ export const betterAuthActions = { return await applyJoinToRows(c, input.model, rows, input.join); }, - // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. - // Schema and behavior are constrained by Better Auth. async betterAuthCountRecords(c, input: { model: string; where?: any[] }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); diff --git a/foundry/packages/backend/src/actors/user/actions/user.ts b/foundry/packages/backend/src/actors/user/actions/user.ts index 714b2b6..f251c95 100644 --- a/foundry/packages/backend/src/actors/user/actions/user.ts +++ b/foundry/packages/backend/src/actors/user/actions/user.ts @@ -1,4 +1,5 @@ -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; +import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared"; import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "../db/schema.js"; import { materializeRow } from "../query-helpers.js"; @@ -41,4 +42,147 @@ export const userActions = { })), }; }, + + // --- Mutation actions (migrated from queue) --- + + async upsertProfile( + c, + input: { + userId: string; + patch: { + githubAccountId?: string | null; + githubLogin?: string | null; + roleLabel?: string; + defaultModel?: string; + eligibleOrganizationIdsJson?: string; + starterRepoStatus?: string; + starterRepoStarredAt?: number | null; + starterRepoSkippedAt?: number | null; + }; + }, + ) { + const now = Date.now(); + await c.db + .insert(userProfiles) + .values({ + id: 1, + userId: input.userId, + githubAccountId: input.patch.githubAccountId ?? null, + githubLogin: input.patch.githubLogin ?? null, + roleLabel: input.patch.roleLabel ?? "GitHub user", + defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID, + eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]", + starterRepoStatus: input.patch.starterRepoStatus ?? "pending", + starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null, + starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: userProfiles.userId, + set: { + ...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}), + ...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}), + ...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}), + ...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}), + ...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}), + ...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}), + ...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}), + ...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}), + updatedAt: now, + }, + }) + .run(); + return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get(); + }, + + async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) { + const now = Date.now(); + await c.db + .insert(sessionState) + .values({ + sessionId: input.sessionId, + activeOrganizationId: input.activeOrganizationId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: sessionState.sessionId, + set: { activeOrganizationId: input.activeOrganizationId, updatedAt: now }, + }) + .run(); + return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(); + }, + + async upsertTaskState( + c, + input: { + taskId: string; + sessionId: string; + patch: { + activeSessionId?: string | null; + unread?: boolean; + draftText?: string; + draftAttachmentsJson?: string; + draftUpdatedAt?: number | null; + }; + }, + ) { + const now = Date.now(); + const existing = await c.db + .select() + .from(userTaskState) + .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) + .get(); + + if (input.patch.activeSessionId !== undefined) { + await c.db + .update(userTaskState) + .set({ activeSessionId: input.patch.activeSessionId, updatedAt: now }) + .where(eq(userTaskState.taskId, input.taskId)) + .run(); + } + + await c.db + .insert(userTaskState) + .values({ + taskId: input.taskId, + sessionId: input.sessionId, + activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null, + unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0), + draftText: input.patch.draftText ?? existing?.draftText ?? "", + draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]", + draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userTaskState.taskId, userTaskState.sessionId], + set: { + ...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}), + ...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}), + ...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}), + ...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}), + ...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}), + updatedAt: now, + }, + }) + .run(); + + return await c.db + .select() + .from(userTaskState) + .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) + .get(); + }, + + async deleteTaskState(c, input: { taskId: string; sessionId?: string }) { + if (input.sessionId) { + await c.db + .delete(userTaskState) + .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) + .run(); + return; + } + await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run(); + }, }; diff --git a/foundry/packages/backend/src/actors/user/index.ts b/foundry/packages/backend/src/actors/user/index.ts index 8a15b58..0deb1cb 100644 --- a/foundry/packages/backend/src/actors/user/index.ts +++ b/foundry/packages/backend/src/actors/user/index.ts @@ -2,17 +2,6 @@ import { actor } from "rivetkit"; import { userDb } from "./db/db.js"; import { betterAuthActions } from "./actions/better-auth.js"; import { userActions } from "./actions/user.js"; -import { - createAuthRecordMutation, - updateAuthRecordMutation, - updateManyAuthRecordsMutation, - deleteAuthRecordMutation, - deleteManyAuthRecordsMutation, - upsertUserProfileMutation, - upsertSessionStateMutation, - upsertTaskStateMutation, - deleteTaskStateMutation, -} from "./workflow.js"; export const user = actor({ db: userDb, @@ -27,34 +16,5 @@ export const user = actor({ actions: { ...betterAuthActions, ...userActions, - async authCreate(c, body) { - return await createAuthRecordMutation(c, body); - }, - async authUpdate(c, body) { - return await updateAuthRecordMutation(c, body); - }, - async authUpdateMany(c, body) { - return await updateManyAuthRecordsMutation(c, body); - }, - async authDelete(c, body) { - await deleteAuthRecordMutation(c, body); - return { ok: true }; - }, - async authDeleteMany(c, body) { - return await deleteManyAuthRecordsMutation(c, body); - }, - async profileUpsert(c, body) { - return await upsertUserProfileMutation(c, body); - }, - async sessionStateUpsert(c, body) { - return await upsertSessionStateMutation(c, body); - }, - async taskStateUpsert(c, body) { - return await upsertTaskStateMutation(c, body); - }, - async taskStateDelete(c, body) { - await deleteTaskStateMutation(c, body); - return { ok: true }; - }, }, }); diff --git a/foundry/packages/backend/src/actors/user/workflow.ts b/foundry/packages/backend/src/actors/user/workflow.ts deleted file mode 100644 index 9bf2675..0000000 --- a/foundry/packages/backend/src/actors/user/workflow.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { eq, count as sqlCount, and } from "drizzle-orm"; -import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared"; -import { authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js"; -import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "./query-helpers.js"; - -export async function createAuthRecordMutation(c: any, input: { model: string; data: Record }) { - const table = tableFor(input.model); - const persisted = persistInput(input.model, input.data); - await c.db - .insert(table) - .values(persisted as any) - .run(); - const row = await c.db - .select() - .from(table) - .where(eq(columnFor(input.model, table, "id"), input.data.id as any)) - .get(); - return materializeRow(input.model, row); -} - -export async function updateAuthRecordMutation(c: any, input: { model: string; where: any[]; update: Record }) { - const table = tableFor(input.model); - const predicate = buildWhere(table, input.where); - if (!predicate) throw new Error("updateAuthRecord requires a where clause"); - await c.db - .update(table) - .set(persistPatch(input.model, input.update) as any) - .where(predicate) - .run(); - return materializeRow(input.model, await c.db.select().from(table).where(predicate).get()); -} - -export async function updateManyAuthRecordsMutation(c: any, input: { model: string; where: any[]; update: Record }) { - const table = tableFor(input.model); - const predicate = buildWhere(table, input.where); - if (!predicate) throw new Error("updateManyAuthRecords requires a where clause"); - await c.db - .update(table) - .set(persistPatch(input.model, input.update) as any) - .where(predicate) - .run(); - const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get(); - return row?.value ?? 0; -} - -export async function deleteAuthRecordMutation(c: any, input: { model: string; where: any[] }) { - const table = tableFor(input.model); - const predicate = buildWhere(table, input.where); - if (!predicate) throw new Error("deleteAuthRecord requires a where clause"); - await c.db.delete(table).where(predicate).run(); -} - -export async function deleteManyAuthRecordsMutation(c: any, input: { model: string; where: any[] }) { - const table = tableFor(input.model); - const predicate = buildWhere(table, input.where); - if (!predicate) throw new Error("deleteManyAuthRecords requires a where clause"); - const rows = await c.db.select().from(table).where(predicate).all(); - await c.db.delete(table).where(predicate).run(); - return rows.length; -} - -export async function upsertUserProfileMutation( - c: any, - input: { - userId: string; - patch: { - githubAccountId?: string | null; - githubLogin?: string | null; - roleLabel?: string; - defaultModel?: string; - eligibleOrganizationIdsJson?: string; - starterRepoStatus?: string; - starterRepoStarredAt?: number | null; - starterRepoSkippedAt?: number | null; - }; - }, -) { - const now = Date.now(); - await c.db - .insert(userProfiles) - .values({ - id: 1, - userId: input.userId, - githubAccountId: input.patch.githubAccountId ?? null, - githubLogin: input.patch.githubLogin ?? null, - roleLabel: input.patch.roleLabel ?? "GitHub user", - defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID, - eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]", - starterRepoStatus: input.patch.starterRepoStatus ?? "pending", - starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null, - starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: userProfiles.userId, - set: { - ...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}), - ...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}), - ...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}), - ...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}), - ...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}), - ...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}), - ...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}), - ...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}), - updatedAt: now, - }, - }) - .run(); - return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get(); -} - -export async function upsertSessionStateMutation(c: any, input: { sessionId: string; activeOrganizationId: string | null }) { - const now = Date.now(); - await c.db - .insert(sessionState) - .values({ - sessionId: input.sessionId, - activeOrganizationId: input.activeOrganizationId, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: sessionState.sessionId, - set: { activeOrganizationId: input.activeOrganizationId, updatedAt: now }, - }) - .run(); - return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(); -} - -export async function upsertTaskStateMutation( - c: any, - input: { - taskId: string; - sessionId: string; - patch: { - activeSessionId?: string | null; - unread?: boolean; - draftText?: string; - draftAttachmentsJson?: string; - draftUpdatedAt?: number | null; - }; - }, -) { - const now = Date.now(); - const existing = await c.db - .select() - .from(userTaskState) - .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) - .get(); - - if (input.patch.activeSessionId !== undefined) { - await c.db.update(userTaskState).set({ activeSessionId: input.patch.activeSessionId, updatedAt: now }).where(eq(userTaskState.taskId, input.taskId)).run(); - } - - await c.db - .insert(userTaskState) - .values({ - taskId: input.taskId, - sessionId: input.sessionId, - activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null, - unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0), - draftText: input.patch.draftText ?? existing?.draftText ?? "", - draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]", - draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [userTaskState.taskId, userTaskState.sessionId], - set: { - ...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}), - ...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}), - ...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}), - ...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}), - ...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}), - updatedAt: now, - }, - }) - .run(); - - return await c.db - .select() - .from(userTaskState) - .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) - .get(); -} - -export async function deleteTaskStateMutation(c: any, input: { taskId: string; sessionId?: string }) { - if (input.sessionId) { - await c.db - .delete(userTaskState) - .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) - .run(); - return; - } - await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run(); -} diff --git a/foundry/packages/backend/src/config/runner-version.ts b/foundry/packages/backend/src/config/runner-version.ts new file mode 100644 index 0000000..5c33672 --- /dev/null +++ b/foundry/packages/backend/src/config/runner-version.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; + +function parseRunnerVersion(rawValue: string | undefined): number | undefined { + const value = rawValue?.trim(); + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + return undefined; + } + + return parsed; +} + +export function resolveRunnerVersion(): number | undefined { + const envVersion = parseRunnerVersion(process.env.RIVET_RUNNER_VERSION); + if (envVersion !== undefined) { + return envVersion; + } + + const versionFilePath = process.env.RIVET_RUNNER_VERSION_FILE; + if (!versionFilePath) { + return undefined; + } + + try { + return parseRunnerVersion(readFileSync(versionFilePath, "utf8")); + } catch { + return undefined; + } +} diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 8f82d8b..617bacc 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -141,6 +141,59 @@ export async function startBackend(options: BackendStartOptions = {}): Promise.json, inspect with chrome://tracing) + app.get("/debug/memory", async (c) => { + if (process.env.NODE_ENV !== "development") { + return c.json({ error: "debug endpoints disabled in production" }, 403); + } + const wantGc = c.req.query("gc") === "1"; + if (wantGc && typeof Bun !== "undefined") { + // Bun.gc(true) triggers a synchronous full GC sweep in JavaScriptCore. + Bun.gc(true); + } + const mem = process.memoryUsage(); + const rssMb = Math.round(mem.rss / 1024 / 1024); + const heapUsedMb = Math.round(mem.heapUsed / 1024 / 1024); + const heapTotalMb = Math.round(mem.heapTotal / 1024 / 1024); + const externalMb = Math.round(mem.external / 1024 / 1024); + const nonHeapMb = rssMb - heapUsedMb - externalMb; + // Bun.heapStats() gives JSC-specific breakdown: object counts, typed array + // bytes, extra memory (native allocations tracked by JSC). Useful for + // distinguishing JS object bloat from native/WASM memory. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const BunAny = Bun as any; + const heapStats = typeof BunAny.heapStats === "function" ? BunAny.heapStats() : null; + const snapshot = { + rssMb, + heapUsedMb, + heapTotalMb, + externalMb, + nonHeapMb, + gcTriggered: wantGc, + rssBytes: mem.rss, + heapUsedBytes: mem.heapUsed, + heapTotalBytes: mem.heapTotal, + externalBytes: mem.external, + ...(heapStats ? { bunHeapStats: heapStats } : {}), + }; + // Optionally write a full JSC heap snapshot for offline analysis. + let heapSnapshotPath: string | null = null; + const wantHeap = c.req.query("heap") === "1"; + if (wantHeap && typeof Bun !== "undefined") { + heapSnapshotPath = `/tmp/foundry-heap-${Date.now()}.json`; + // Bun.generateHeapSnapshot("v8") returns a V8-compatible JSON string. + const heapJson = Bun.generateHeapSnapshot("v8"); + await Bun.write(heapSnapshotPath, heapJson); + } + logger.info(snapshot, "memory_usage_debug"); + return c.json({ ...snapshot, ...(heapSnapshotPath ? { heapSnapshotPath } : {}) }); + }); + app.use("*", async (c, next) => { const requestId = c.req.header("x-request-id")?.trim() || randomUUID(); const start = performance.now(); @@ -228,7 +281,55 @@ export async function startBackend(options: BackendStartOptions = {}): Promise Fastly -> Railway) retries callback requests when they take + // >10s. The first request deletes the verification record on success, so the + // retry fails with "verification not found" -> ?error=please_restart_the_process. + // This map tracks in-flight callbacks by state param so retries wait for and + // reuse the first request's response. + const inflightCallbacks = new Map>(); + app.all("/v1/auth/*", async (c) => { + const authPath = c.req.path; + const authMethod = c.req.method; + const isCallback = authPath.includes("/callback/"); + + // Deduplicate callback requests by OAuth state parameter + if (isCallback) { + const url = new URL(c.req.url); + const state = url.searchParams.get("state"); + if (state) { + const existing = inflightCallbacks.get(state); + if (existing) { + logger.info({ path: authPath, state: state.slice(0, 8) + "..." }, "auth_callback_dedup"); + const original = await existing; + return original.clone(); + } + + const promise = (async () => { + logger.info({ path: authPath, method: authMethod, state: state.slice(0, 8) + "..." }, "auth_callback_start"); + const start = performance.now(); + const response = await betterAuth.auth.handler(c.req.raw); + const durationMs = Math.round((performance.now() - start) * 100) / 100; + const location = response.headers.get("location"); + logger.info({ path: authPath, status: response.status, durationMs, location: location ?? undefined }, "auth_callback_complete"); + if (location && location.includes("error=")) { + logger.error({ path: authPath, status: response.status, durationMs, location }, "auth_callback_error_redirect"); + } + return response; + })(); + + inflightCallbacks.set(state, promise); + try { + const response = await promise; + return response.clone(); + } finally { + // Keep entry briefly so late retries still hit the cache + setTimeout(() => inflightCallbacks.delete(state), 30_000); + } + } + } + return await betterAuth.auth.handler(c.req.raw); }); @@ -306,6 +407,11 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + const mem = process.memoryUsage(); + const rssMb = Math.round(mem.rss / 1024 / 1024); + const heapUsedMb = Math.round(mem.heapUsed / 1024 / 1024); + const heapTotalMb = Math.round(mem.heapTotal / 1024 / 1024); + const externalMb = Math.round(mem.external / 1024 / 1024); + // Non-heap RSS: memory not accounted for by JS heap or external buffers. + // Large values here point to native allocations (WASM, mmap, child process + // bookkeeping, Bun's internal arena, etc.). + const nonHeapMb = rssMb - heapUsedMb - externalMb; + const deltaRss = rssMb - prevRss; + prevRss = rssMb; + logger.info( + { + rssMb, + heapUsedMb, + heapTotalMb, + externalMb, + nonHeapMb, + deltaRssMb: deltaRss, + rssBytes: mem.rss, + heapUsedBytes: mem.heapUsed, + heapTotalBytes: mem.heapTotal, + externalBytes: mem.external, + }, + "memory_usage", + ); + }, 60_000); + } + process.on("SIGINT", async () => { server.stop(); process.exit(0); diff --git a/foundry/packages/backend/src/services/app-github.ts b/foundry/packages/backend/src/services/app-github.ts index 6cb6db3..52e5308 100644 --- a/foundry/packages/backend/src/services/app-github.ts +++ b/foundry/packages/backend/src/services/app-github.ts @@ -41,11 +41,6 @@ export interface GitHubRepositoryRecord { defaultBranch: string; } -export interface GitHubBranchRecord { - name: string; - commitSha: string; -} - export interface GitHubMemberRecord { id: string; login: string; @@ -402,15 +397,6 @@ export class GitHubAppClient { return await this.getUserRepository(accessToken, fullName); } - async listUserRepositoryBranches(accessToken: string, fullName: string): Promise { - return await this.listRepositoryBranches(accessToken, fullName); - } - - async listInstallationRepositoryBranches(installationId: number, fullName: string): Promise { - const accessToken = await this.createInstallationAccessToken(installationId); - return await this.listRepositoryBranches(accessToken, fullName); - } - async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise { const members = await this.paginate<{ id: number; @@ -708,20 +694,6 @@ export class GitHubAppClient { nextUrl: parseNextLink(response.headers.get("link")), }; } - - private async listRepositoryBranches(accessToken: string, fullName: string): Promise { - const branches = await this.paginate<{ - name: string; - commit?: { sha?: string | null } | null; - }>(`/repos/${fullName}/branches?per_page=100`, accessToken); - - return branches - .map((branch) => ({ - name: branch.name?.trim() ?? "", - commitSha: branch.commit?.sha?.trim() ?? "", - })) - .filter((branch) => branch.name.length > 0 && branch.commitSha.length > 0); - } } function parseNextLink(linkHeader: string | null): string | null { diff --git a/foundry/packages/backend/src/services/better-auth.ts b/foundry/packages/backend/src/services/better-auth.ts index c36b900..23d227f 100644 --- a/foundry/packages/backend/src/services/better-auth.ts +++ b/foundry/packages/backend/src/services/better-auth.ts @@ -1,11 +1,8 @@ import { betterAuth } from "better-auth"; import { createAdapterFactory } from "better-auth/adapters"; import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js"; -// organization actions are called directly (no queue) -// user actor actions are called directly (no queue) import { organizationKey, userKey } from "../actors/keys.js"; import { logger } from "../logging.js"; -// expectQueueResponse removed — actions return values directly const AUTH_BASE_PATH = "/v1/auth"; const SESSION_COOKIE = "better-auth.session_token"; @@ -62,8 +59,6 @@ function resolveRouteUserId(organization: any, resolved: any): string | null { return null; } -// sendOrganizationCommand removed — org actions are called directly - export interface BetterAuthService { auth: any; resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>; @@ -162,11 +157,6 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return null; }; - const ensureOrganizationVerification = async (actionName: string, payload: Record) => { - const organization = await appOrganization(); - return await (organization as any)[actionName](payload); - }; - return { options: { useDatabaseGeneratedIds: false, @@ -175,7 +165,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin create: async ({ model, data }) => { const transformed = await transformInput(data, model, "create", true); if (model === "verification") { - return await ensureOrganizationVerification("commandBetterAuthVerificationCreate", { data: transformed }); + const organization = await appOrganization(); + return await organization.betterAuthCreateVerification({ data: transformed }); } const userId = await resolveUserIdForQuery(model, undefined, transformed); @@ -184,18 +175,18 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } const userActor = await getUser(userId); - const created = await userActor.authCreate({ model, data: transformed }); + const created = await userActor.betterAuthCreateRecord({ model, data: transformed }); const organization = await appOrganization(); if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) { - await organization.commandBetterAuthEmailIndexUpsert({ + await organization.betterAuthUpsertEmailIndex({ email: transformed.email.toLowerCase(), userId, }); } if (model === "session") { - await organization.commandBetterAuthSessionIndexUpsert({ + await organization.betterAuthUpsertSessionIndex({ sessionId: String(created.id), sessionToken: String(created.token), userId, @@ -203,7 +194,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } if (model === "account") { - await organization.commandBetterAuthAccountIndexUpsert({ + await organization.betterAuthUpsertAccountIndex({ id: String(created.id), providerId: String(created.providerId), accountId: String(created.accountId), @@ -291,7 +282,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin const transformedWhere = transformWhereClause({ model, where, action: "update" }); const transformedUpdate = (await transformInput(update as Record, model, "update", true)) as Record; if (model === "verification") { - return await ensureOrganizationVerification("commandBetterAuthVerificationUpdate", { + const organization = await appOrganization(); + return await organization.betterAuthUpdateVerification({ where: transformedWhere, update: transformedUpdate, }); @@ -311,17 +303,21 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin : model === "session" ? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere }) : null; - const updated = await userActor.authUpdate({ model, where: transformedWhere, update: transformedUpdate }); + const updated = await userActor.betterAuthUpdateRecord({ + model, + where: transformedWhere, + update: transformedUpdate, + }); const organization = await appOrganization(); if (model === "user" && updated) { if (before?.email && before.email !== updated.email) { - await organization.commandBetterAuthEmailIndexDelete({ + await organization.betterAuthDeleteEmailIndex({ email: before.email.toLowerCase(), }); } if (updated.email) { - await organization.commandBetterAuthEmailIndexUpsert({ + await organization.betterAuthUpsertEmailIndex({ email: updated.email.toLowerCase(), userId, }); @@ -329,7 +325,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } if (model === "session" && updated) { - await organization.commandBetterAuthSessionIndexUpsert({ + await organization.betterAuthUpsertSessionIndex({ sessionId: String(updated.id), sessionToken: String(updated.token), userId, @@ -337,7 +333,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } if (model === "account" && updated) { - await organization.commandBetterAuthAccountIndexUpsert({ + await organization.betterAuthUpsertAccountIndex({ id: String(updated.id), providerId: String(updated.providerId), accountId: String(updated.accountId), @@ -352,7 +348,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin const transformedWhere = transformWhereClause({ model, where, action: "updateMany" }); const transformedUpdate = (await transformInput(update as Record, model, "update", true)) as Record; if (model === "verification") { - return await ensureOrganizationVerification("commandBetterAuthVerificationUpdateMany", { + const organization = await appOrganization(); + return await organization.betterAuthUpdateManyVerification({ where: transformedWhere, update: transformedUpdate, }); @@ -364,14 +361,18 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } const userActor = await getUser(userId); - return await userActor.authUpdateMany({ model, where: transformedWhere, update: transformedUpdate }); + return await userActor.betterAuthUpdateManyRecords({ + model, + where: transformedWhere, + update: transformedUpdate, + }); }, delete: async ({ model, where }) => { const transformedWhere = transformWhereClause({ model, where, action: "delete" }); if (model === "verification") { const organization = await appOrganization(); - await organization.commandBetterAuthVerificationDelete({ where: transformedWhere }); + await organization.betterAuthDeleteVerification({ where: transformedWhere }); return; } @@ -383,17 +384,17 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin const userActor = await getUser(userId); const organization = await appOrganization(); const before = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere }); - await userActor.authDelete({ model, where: transformedWhere }); + await userActor.betterAuthDeleteRecord({ model, where: transformedWhere }); if (model === "session" && before) { - await organization.commandBetterAuthSessionIndexDelete({ + await organization.betterAuthDeleteSessionIndex({ sessionId: before.id, sessionToken: before.token, }); } if (model === "account" && before) { - await organization.commandBetterAuthAccountIndexDelete({ + await organization.betterAuthDeleteAccountIndex({ id: before.id, providerId: before.providerId, accountId: before.accountId, @@ -401,7 +402,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } if (model === "user" && before?.email) { - await organization.commandBetterAuthEmailIndexDelete({ + await organization.betterAuthDeleteEmailIndex({ email: before.email.toLowerCase(), }); } @@ -410,7 +411,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin deleteMany: async ({ model, where }) => { const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" }); if (model === "verification") { - return await ensureOrganizationVerification("commandBetterAuthVerificationDeleteMany", { where: transformedWhere }); + const organization = await appOrganization(); + return await organization.betterAuthDeleteManyVerification({ where: transformedWhere }); } if (model === "session") { @@ -421,9 +423,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin const userActor = await getUser(userId); const organization = await appOrganization(); const sessions = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit: 5000 }); - const deleted = await userActor.authDeleteMany({ model, where: transformedWhere }); + const deleted = await userActor.betterAuthDeleteManyRecords({ model, where: transformedWhere }); for (const session of sessions) { - await organization.commandBetterAuthSessionIndexDelete({ + await organization.betterAuthDeleteSessionIndex({ sessionId: session.id, sessionToken: session.token, }); @@ -437,8 +439,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } const userActor = await getUser(userId); - const deleted = await userActor.authDeleteMany({ model, where: transformedWhere }); - return deleted; + return await userActor.betterAuthDeleteManyRecords({ model, where: transformedWhere }); }, count: async ({ model, where }) => { @@ -473,6 +474,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin strategy: "compact", }, }, + onAPIError: { + errorURL: stripTrailingSlash(options.appUrl) + "/signin", + }, socialProviders: { github: { clientId: requireEnv("GITHUB_CLIENT_ID"), @@ -509,7 +513,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin async upsertUserProfile(userId: string, patch: Record) { const userActor = await getUser(userId); - return await userActor.profileUpsert({ userId, patch }); + return await userActor.upsertProfile({ userId, patch }); }, async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) { @@ -518,7 +522,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin throw new Error(`Unknown auth session ${sessionId}`); } const userActor = await getUser(authState.user.id); - return await userActor.sessionStateUpsert({ sessionId, activeOrganizationId }); + return await userActor.upsertSessionState({ sessionId, activeOrganizationId }); }, async getAccessTokenForSession(sessionId: string) { diff --git a/foundry/packages/client/package.json b/foundry/packages/client/package.json index 9790474..fa73dab 100644 --- a/foundry/packages/client/package.json +++ b/foundry/packages/client/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/index.ts --format esm --dts", + "build": "tsup src/index.ts --format esm --dts --tsconfig tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts", diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 0903aa8..c2222cc 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -12,6 +12,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -110,6 +111,7 @@ interface OrganizationHandle { stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise; + changeWorkspaceTaskOwner(input: TaskWorkspaceChangeOwnerInput & AuthSessionScopedInput): Promise; revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(input: { repoId: string }): Promise; @@ -304,6 +306,7 @@ export interface BackendClient { stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise; + changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise; revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise; adminReloadGithubOrganization(organizationId: string): Promise; adminReloadGithubRepository(organizationId: string, repoId: string): Promise; @@ -1282,6 +1285,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input)); }, + async changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise { + await (await organization(organizationId)).changeWorkspaceTaskOwner(await withAuthSessionInput(input)); + }, + async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise { await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input)); }, diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index fc6470c..191f68c 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -188,6 +188,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back unread: tab.unread, created: tab.created, })), + primaryUserLogin: null, + primaryUserAvatarUrl: null, }); const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({ @@ -202,6 +204,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back sandboxProviderId: "local", sandboxId: task.id, cwd: mockCwd(task.repoName, task.id), + url: null, }, ], activeSandboxId: task.id, @@ -750,6 +753,15 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back emitTaskUpdate(input.taskId); }, + async changeWorkspaceTaskOwner( + _organizationId: string, + input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }, + ): Promise { + await workspace.changeOwner(input); + emitOrganizationSnapshot(); + emitTaskUpdate(input.taskId); + }, + async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise { await workspace.revertFile(input); emitOrganizationSnapshot(); diff --git a/foundry/packages/client/src/mock/workspace-client.ts b/foundry/packages/client/src/mock/workspace-client.ts index c51b2e8..7983e0f 100644 --- a/foundry/packages/client/src/mock/workspace-client.ts +++ b/foundry/packages/client/src/mock/workspace-client.ts @@ -349,7 +349,10 @@ class MockWorkspaceStore implements TaskWorkspaceClient { return { ...currentTask, - activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId, + activeSessionId: + currentTask.activeSessionId === input.sessionId + ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) + : currentTask.activeSessionId, sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId), }; }); @@ -396,6 +399,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient { })); } + async changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise { + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + primaryUserLogin: input.targetUserName, + primaryUserAvatarUrl: null, + })); + } + private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void { const nextSnapshot = updater(this.snapshot); this.snapshot = { diff --git a/foundry/packages/client/src/remote/workspace-client.ts b/foundry/packages/client/src/remote/workspace-client.ts index 1b6bc8e..2a11f51 100644 --- a/foundry/packages/client/src/remote/workspace-client.ts +++ b/foundry/packages/client/src/remote/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -140,6 +141,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient { await this.refresh(); } + async changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise { + await this.backend.changeWorkspaceTaskOwner(this.organizationId, input); + await this.refresh(); + } + private ensureStarted(): void { if (!this.unsubscribeWorkspace) { this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => { diff --git a/foundry/packages/client/src/subscription/remote-manager.ts b/foundry/packages/client/src/subscription/remote-manager.ts index 778241f..ae774c6 100644 --- a/foundry/packages/client/src/subscription/remote-manager.ts +++ b/foundry/packages/client/src/subscription/remote-manager.ts @@ -4,6 +4,11 @@ import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, const GRACE_PERIOD_MS = 30_000; +/** Initial retry delay in ms. */ +const RETRY_BASE_MS = 1_000; +/** Maximum retry delay in ms. */ +const RETRY_MAX_MS = 30_000; + /** * Remote implementation of SubscriptionManager. * Each cache entry owns one actor connection plus one materialized snapshot. @@ -80,9 +85,12 @@ class TopicEntry { private unsubscribeEvent: (() => void) | null = null; private unsubscribeError: (() => void) | null = null; private teardownTimer: ReturnType | null = null; + private retryTimer: ReturnType | null = null; + private retryAttempt = 0; private startPromise: Promise | null = null; private eventPromise: Promise = Promise.resolve(); private started = false; + private disposed = false; constructor( private readonly topicKey: TopicKey, @@ -136,7 +144,9 @@ class TopicEntry { } dispose(): void { + this.disposed = true; this.cancelTeardown(); + this.cancelRetry(); this.unsubscribeEvent?.(); this.unsubscribeError?.(); if (this.conn) { @@ -148,6 +158,55 @@ class TopicEntry { this.error = null; this.lastRefreshAt = null; this.started = false; + this.retryAttempt = 0; + } + + private cancelRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + } + + /** + * Schedules a retry with exponential backoff. Cleans up any existing + * connection state before reconnecting. + */ + private scheduleRetry(): void { + if (this.disposed || this.listenerCount === 0) { + return; + } + + const delay = Math.min(RETRY_BASE_MS * 2 ** this.retryAttempt, RETRY_MAX_MS); + this.retryAttempt++; + + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + if (this.disposed || this.listenerCount === 0) { + return; + } + + // Tear down the old connection before retrying + this.cleanupConnection(); + this.started = false; + this.startPromise = this.start().finally(() => { + this.startPromise = null; + }); + }, delay); + } + + /** + * Cleans up connection resources without resetting data/status/retry state. + */ + private cleanupConnection(): void { + this.unsubscribeEvent?.(); + this.unsubscribeError?.(); + this.unsubscribeEvent = null; + this.unsubscribeError = null; + if (this.conn) { + void this.conn.dispose(); + } + this.conn = null; } private async start(): Promise { @@ -164,17 +223,20 @@ class TopicEntry { this.status = "error"; this.error = error instanceof Error ? error : new Error(String(error)); this.notify(); + this.scheduleRetry(); }); this.data = await this.definition.fetchInitial(this.backend, this.params); this.status = "connected"; this.lastRefreshAt = Date.now(); this.started = true; + this.retryAttempt = 0; this.notify(); } catch (error) { this.status = "error"; this.error = error instanceof Error ? error : new Error(String(error)); this.started = false; this.notify(); + this.scheduleRetry(); } } diff --git a/foundry/packages/client/src/workspace-client.ts b/foundry/packages/client/src/workspace-client.ts index c3293a0..6662352 100644 --- a/foundry/packages/client/src/workspace-client.ts +++ b/foundry/packages/client/src/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -43,6 +44,7 @@ export interface TaskWorkspaceClient { closeSession(input: TaskWorkspaceSessionInput): Promise; addSession(input: TaskWorkspaceSelectInput): Promise; changeModel(input: TaskWorkspaceChangeModelInput): Promise; + changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise; } export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient { diff --git a/foundry/packages/client/test/subscription-manager.test.ts b/foundry/packages/client/test/subscription-manager.test.ts index c064606..f0a29c2 100644 --- a/foundry/packages/client/test/subscription-manager.test.ts +++ b/foundry/packages/client/test/subscription-manager.test.ts @@ -77,6 +77,8 @@ function organizationSnapshot(): OrganizationSummarySnapshot { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }; @@ -159,6 +161,8 @@ describe("RemoteSubscriptionManager", () => { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }, diff --git a/foundry/packages/client/tsconfig.build.json b/foundry/packages/client/tsconfig.build.json new file mode 100644 index 0000000..35bcdb2 --- /dev/null +++ b/foundry/packages/client/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "ignoreDeprecations": "6.0" + } +} diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index 042b5a4..4089e01 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -42,7 +42,7 @@ import { type Message, type ModelId, } from "./mock-layout/view-model"; -import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import { activeMockOrganization, activeMockUser, getMockOrganizationById, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; import { backendClient } from "../lib/backend"; import { subscriptionManager } from "../lib/subscription"; import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status"; @@ -187,7 +187,10 @@ function toTaskModel( diffs: detail?.diffs ?? {}, fileTree: detail?.fileTree ?? [], minutesUsed: detail?.minutesUsed ?? 0, + sandboxes: detail?.sandboxes ?? [], activeSandboxId: detail?.activeSandboxId ?? null, + primaryUserLogin: detail?.primaryUserLogin ?? summary.primaryUserLogin ?? null, + primaryUserAvatarUrl: detail?.primaryUserAvatarUrl ?? summary.primaryUserAvatarUrl ?? null, }; } @@ -264,6 +267,7 @@ interface WorkspaceActions { closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise; addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>; changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise; + changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(repoId: string): Promise; } @@ -1069,6 +1073,8 @@ const RightRail = memo(function RightRail({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { organizationId: string; @@ -1078,6 +1084,8 @@ const RightRail = memo(function RightRail({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); @@ -1170,6 +1178,8 @@ const RightRail = memo(function RightRail({ onArchive={onArchive} onRevertFile={onRevertFile} onPublishPr={onPublishPr} + onChangeOwner={onChangeOwner} + members={members} onToggleSidebar={onToggleSidebar} />
@@ -1311,6 +1321,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input), addSession: (input) => backendClient.createWorkspaceSession(organizationId, input), changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input), + changeOwner: (input) => backendClient.changeWorkspaceTaskOwner(organizationId, input), adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId), adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId), }), @@ -1741,6 +1752,22 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } [tasks], ); + const changeOwner = useCallback( + (member: { id: string; name: string; email: string }) => { + if (!activeTask) { + throw new Error("Cannot change owner without an active task"); + } + void taskWorkspaceClient.changeOwner({ + repoId: activeTask.repoId, + taskId: activeTask.id, + targetUserId: member.id, + targetUserName: member.name, + targetUserEmail: member.email, + }); + }, + [activeTask], + ); + const archiveTask = useCallback(() => { if (!activeTask) { throw new Error("Cannot archive without an active task"); @@ -2167,6 +2194,8 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onArchive={archiveTask} onRevertFile={revertFile} onPublishPr={publishPr} + onChangeOwner={changeOwner} + members={getMockOrganizationById(appSnapshot, organizationId)?.members ?? []} onToggleSidebar={() => setRightSidebarOpen(false)} /> diff --git a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx index cd4c33a..3565b44 100644 --- a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -1,7 +1,21 @@ -import { memo, useCallback, useMemo, useState, type MouseEvent } from "react"; +import { memo, useCallback, useMemo, useRef, useState, type MouseEvent } from "react"; import { useStyletron } from "baseui"; -import { LabelSmall } from "baseui/typography"; -import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react"; +import { LabelSmall, LabelXSmall } from "baseui/typography"; +import { + Archive, + ArrowUpFromLine, + ChevronDown, + ChevronRight, + FileCode, + FilePlus, + FileX, + FolderOpen, + ExternalLink, + GitBranch, + GitPullRequest, + PanelRight, + User, +} from "lucide-react"; import { useFoundryTokens } from "../../app/theme"; import { createErrorContext } from "@sandbox-agent/foundry-shared"; @@ -99,6 +113,8 @@ export const RightSidebar = memo(function RightSidebar({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { task: Task; @@ -107,11 +123,13 @@ export const RightSidebar = memo(function RightSidebar({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); - const [rightTab, setRightTab] = useState<"changes" | "files">("changes"); + const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("overview"); const contextMenu = useContextMenu(); const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); const isTerminal = task.status === "archived"; @@ -125,6 +143,8 @@ export const RightSidebar = memo(function RightSidebar({ }); observer.observe(node); }, []); + const [ownerDropdownOpen, setOwnerDropdownOpen] = useState(false); + const ownerDropdownRef = useRef(null); const pullRequestUrl = task.pullRequest?.url ?? null; const copyFilePath = useCallback(async (path: string) => { @@ -310,7 +330,7 @@ export const RightSidebar = memo(function RightSidebar({ })} > + + + {isActive && !liveViewActive && ( + + )} + + {isActive && !liveViewActive && ( +
+
+ + +
+ {screenshotFormat !== "png" && ( +
+ + setScreenshotQuality(event.target.value)} + inputMode="numeric" + style={{ maxWidth: 60 }} + /> +
+ )} +
+ + setScreenshotScale(event.target.value)} + inputMode="decimal" + style={{ maxWidth: 60 }} + /> +
+ +
+ )} + {error &&
{error}
} + {screenshotError &&
{screenshotError}
} + {/* ========== Runtime Section ========== */} +
+
+ + + Desktop Runtime + + + {status?.state ?? "unknown"} + +
+
+
+
Display
+
{status?.display ?? "Not assigned"}
+
+
+
Resolution
+
{resolutionLabel}
+
+
+
Started
+
{formatStartedAt(status?.startedAt)}
+
+
+
+
+ + setWidth(event.target.value)} inputMode="numeric" /> +
+
+ + setHeight(event.target.value)} inputMode="numeric" /> +
+
+ + setDpi(event.target.value)} inputMode="numeric" /> +
+
+ + {showAdvancedStart && ( +
+
+ + +
+
+ + +
+
+ + setStreamFrameRate(event.target.value)} + inputMode="numeric" + disabled={isActive} + /> +
+
+ + setWebrtcPortRange(event.target.value)} disabled={isActive} /> +
+
+ + setDefaultRecordingFps(event.target.value)} + inputMode="numeric" + disabled={isActive} + /> +
+
+ )} +
+ {isActive ? ( + + ) : ( + + )} +
+
+ {/* ========== Missing Dependencies ========== */} + {status?.missingDependencies && status.missingDependencies.length > 0 && ( +
+
+ Missing Dependencies +
+
+ {status.missingDependencies.map((dependency) => ( + + {dependency} + + ))} +
+ {status.installCommand && ( + <> +
+ Install command +
+
{status.installCommand}
+ + )} +
+ )} + {/* ========== Live View Section ========== */} +
+
+ + + {isActive && ( + + )} +
+ {liveViewError && ( +
+ {liveViewError} +
+ )} + {!isActive &&
Start the desktop runtime to enable live view.
} + {isActive && liveViewActive && ( + <> +
+ Right click to open window + {status?.resolution && ( + + {status.resolution.width}x{status.resolution.height} + + )} +
+ + + )} + {isActive && !liveViewActive && ( + <> + {screenshotUrl ? ( +
+ Desktop screenshot +
+ ) : ( +
Click "Start Stream" for live desktop view, or use the Screenshot button above.
+ )} + + )} + {isActive && ( +
+ + {mousePos && ( + + ({mousePos.x}, {mousePos.y}) + + )} +
+ )} +
+ {isActive && ( +
+
+ + + Clipboard + +
+ + +
+
+ {clipboardError && ( +
+ {clipboardError} +
+ )} +
+
Current contents
+
+              {clipboardText ? clipboardText : (empty)}
+            
+
+
+
Write to clipboard
+