mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
Merge branch 'main' into NathanFlurry/pi-bootstrap-fix
This commit is contained in:
commit
7924e11a23
671 changed files with 52836 additions and 28750 deletions
|
|
@ -17,7 +17,7 @@ coverage/
|
|||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
.openhandoff/
|
||||
.foundry/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
|
|
|||
34
.env.development.example
Normal file
34
.env.development.example
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Foundry local development environment.
|
||||
# Copy ~/misc/the-foundry.env to .env in the repo root to populate secrets.
|
||||
# .env is gitignored — never commit it. The source of truth is ~/misc/the-foundry.env.
|
||||
#
|
||||
# Docker Compose (just foundry-dev) and the justfile (set dotenv-load := true)
|
||||
# both read .env automatically.
|
||||
|
||||
APP_URL=http://localhost:4173
|
||||
BETTER_AUTH_URL=http://localhost:4173
|
||||
BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me
|
||||
GITHUB_REDIRECT_URI=http://localhost:4173/v1/auth/callback/github
|
||||
|
||||
# Fill these in when enabling live GitHub OAuth.
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Fill these in when enabling GitHub App-backed org installation and repo import.
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_CLIENT_ID=
|
||||
GITHUB_APP_CLIENT_SECRET=
|
||||
# Store PEM material as a quoted single-line value with \n escapes.
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
# Webhook secret for verifying GitHub webhook payloads.
|
||||
# Use smee.io for local development: https://smee.io/new
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
# Required for local GitHub webhook forwarding in compose.dev.
|
||||
SMEE_URL=
|
||||
SMEE_TARGET=http://backend:7741/v1/webhooks/github
|
||||
|
||||
# Fill these in when enabling live Stripe billing.
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_TEAM=
|
||||
31
.github/workflows/ci.yaml
vendored
31
.github/workflows/ci.yaml
vendored
|
|
@ -11,6 +11,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
|
@ -21,6 +23,35 @@ jobs:
|
|||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- name: Run formatter hooks
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
git fetch origin "${{ github.base_ref }}" --depth=1
|
||||
diff_range="origin/${{ github.base_ref }}...HEAD"
|
||||
elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
|
||||
diff_range="${{ github.event.before }}...${{ github.sha }}"
|
||||
else
|
||||
diff_range="HEAD^...HEAD"
|
||||
fi
|
||||
|
||||
mapfile -t changed_files < <(
|
||||
git diff --name-only --diff-filter=ACMR "$diff_range" \
|
||||
| grep -E '\.(cjs|cts|js|jsx|json|jsonc|mjs|mts|rs|ts|tsx)$' \
|
||||
|| true
|
||||
)
|
||||
|
||||
if [ ${#changed_files[@]} -eq 0 ]; then
|
||||
echo "No formatter-managed files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
args=()
|
||||
for file in "${changed_files[@]}"; do
|
||||
args+=(--file "$file")
|
||||
done
|
||||
|
||||
pnpm exec lefthook run pre-commit --no-stage-fixed --fail-on-changes "${args[@]}"
|
||||
- run: npm install -g tsx
|
||||
- name: Run checks
|
||||
run: ./scripts/release/main.ts --version 0.0.0 --only-steps run-ci-checks
|
||||
|
|
|
|||
18
.github/workflows/release.yaml
vendored
18
.github/workflows/release.yaml
vendored
|
|
@ -180,10 +180,20 @@ jobs:
|
|||
include:
|
||||
- platform: linux/arm64
|
||||
runner: depot-ubuntu-24.04-arm-8
|
||||
arch_suffix: -arm64
|
||||
tag_suffix: -arm64
|
||||
dockerfile: docker/runtime/Dockerfile
|
||||
- platform: linux/amd64
|
||||
runner: depot-ubuntu-24.04-8
|
||||
arch_suffix: -amd64
|
||||
tag_suffix: -amd64
|
||||
dockerfile: docker/runtime/Dockerfile
|
||||
- platform: linux/arm64
|
||||
runner: depot-ubuntu-24.04-arm-8
|
||||
tag_suffix: -full-arm64
|
||||
dockerfile: docker/runtime/Dockerfile.full
|
||||
- platform: linux/amd64
|
||||
runner: depot-ubuntu-24.04-8
|
||||
tag_suffix: -full-amd64
|
||||
dockerfile: docker/runtime/Dockerfile.full
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -205,8 +215,8 @@ jobs:
|
|||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: rivetdev/sandbox-agent:${{ steps.vars.outputs.sha_short }}${{ matrix.arch_suffix }}
|
||||
file: docker/runtime/Dockerfile
|
||||
tags: rivetdev/sandbox-agent:${{ steps.vars.outputs.sha_short }}${{ matrix.tag_suffix }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
TARGETARCH=${{ contains(matrix.platform, 'arm64') && 'arm64' || 'amd64' }}
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -51,7 +51,11 @@ Cargo.lock
|
|||
# Example temp files
|
||||
.tmp-upload/
|
||||
*.db
|
||||
.openhandoff/
|
||||
.foundry/
|
||||
|
||||
# CLI binaries (downloaded during npm publish)
|
||||
sdks/cli/platforms/*/bin/
|
||||
|
||||
# Foundry desktop app build artifacts
|
||||
foundry/packages/desktop/frontend-dist/
|
||||
foundry/packages/desktop/src-tauri/sidecars/
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"everything": {
|
||||
"args": [
|
||||
"@modelcontextprotocol/server-everything"
|
||||
],
|
||||
"args": ["@modelcontextprotocol/server-everything"],
|
||||
"command": "npx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
CLAUDE.md
80
CLAUDE.md
|
|
@ -1,38 +1,5 @@
|
|||
# Instructions
|
||||
|
||||
## ACP v1 Baseline
|
||||
|
||||
- v1 is ACP-native.
|
||||
- `/v1/*` is removed and returns `410 Gone` (`application/problem+json`).
|
||||
- `/opencode/*` is disabled during ACP core phases and returns `503`.
|
||||
- Prompt/session traffic is ACP JSON-RPC over streamable HTTP on `/v1/rpc`:
|
||||
- `POST /v1/rpc`
|
||||
- `GET /v1/rpc` (SSE)
|
||||
- `DELETE /v1/rpc`
|
||||
- Control-plane endpoints:
|
||||
- `GET /v1/health`
|
||||
- `GET /v1/agents`
|
||||
- `POST /v1/agents/{agent}/install`
|
||||
- Binary filesystem transfer endpoints (intentionally HTTP, not ACP extension methods):
|
||||
- `GET /v1/fs/file`
|
||||
- `PUT /v1/fs/file`
|
||||
- `POST /v1/fs/upload-batch`
|
||||
- Sandbox Agent ACP extension method naming:
|
||||
- Custom ACP methods use `_sandboxagent/...` (not `_sandboxagent/v1/...`).
|
||||
- Session detach method is `_sandboxagent/session/detach`.
|
||||
|
||||
## API Scope
|
||||
|
||||
- ACP is the primary protocol for agent/session behavior and all functionality that talks directly to the agent.
|
||||
- ACP extensions may be used for gaps (for example `skills`, `models`, and related metadata), but the default is that agent-facing behavior is implemented by the agent through ACP.
|
||||
- Custom HTTP APIs are for non-agent/session platform services (for example filesystem, terminals, and other host/runtime capabilities).
|
||||
- Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP.
|
||||
- Keep `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch` on HTTP:
|
||||
- These are Sandbox Agent host/runtime operations with cross-agent-consistent behavior.
|
||||
- They may involve very large binary transfers that ACP JSON-RPC envelopes are not suited to stream.
|
||||
- This is intentionally separate from ACP native `fs/read_text_file` and `fs/write_text_file`.
|
||||
- ACP extension variants may exist in parallel, but SDK defaults should prefer HTTP for these binary transfer operations.
|
||||
|
||||
## Naming and Ownership
|
||||
|
||||
- This repository/product is **Sandbox Agent**.
|
||||
|
|
@ -41,46 +8,23 @@
|
|||
- Canonical extension namespace/domain string is `sandboxagent.dev` (no hyphen).
|
||||
- Canonical custom ACP extension method prefix is `_sandboxagent/...` (no hyphen).
|
||||
|
||||
## Architecture (Brief)
|
||||
## Docs Terminology
|
||||
|
||||
- HTTP contract and problem/error mapping: `server/packages/sandbox-agent/src/router.rs`
|
||||
- ACP client runtime and agent process bridge: `server/packages/sandbox-agent/src/acp_runtime/mod.rs`
|
||||
- Agent/native + ACP agent process install and lazy install: `server/packages/agent-management/`
|
||||
- Inspector UI served at `/ui/` and bound to ACP over HTTP from `frontend/packages/inspector/`
|
||||
- Never mention "ACP" in user-facing docs (`docs/**/*.mdx`) except in docs that are specifically about ACP itself (e.g. `docs/acp-http-client.mdx`).
|
||||
- Never expose underlying protocol method names (e.g. `session/request_permission`, `session/create`, `_sandboxagent/session/detach`) in non-ACP docs. Describe the behavior in user-facing terms instead.
|
||||
- Do not describe the underlying protocol implementation in docs. Only document the SDK surface (methods, types, options). ACP protocol details belong exclusively in ACP-specific pages.
|
||||
- Do not use em dashes (`—`) in docs. Use commas, periods, or parentheses instead.
|
||||
|
||||
## TypeScript SDK Architecture
|
||||
### Docs Source Of Truth (HTTP/CLI)
|
||||
|
||||
- TypeScript clients are split into:
|
||||
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers.
|
||||
- `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers.
|
||||
- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
|
||||
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`.
|
||||
- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`.
|
||||
- Cleanup is `sdk.dispose()`.
|
||||
|
||||
### Docs Source Of Truth
|
||||
|
||||
- For TypeScript docs/examples, source of truth is implementation in:
|
||||
- `sdks/typescript/src/client.ts`
|
||||
- `sdks/typescript/src/index.ts`
|
||||
- `sdks/acp-http-client/src/index.ts`
|
||||
- Do not document TypeScript APIs unless they are exported and implemented in those files.
|
||||
- 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).
|
||||
|
||||
## Source Documents
|
||||
|
||||
- `~/misc/acp-docs/schema/schema.json`
|
||||
- `~/misc/acp-docs/schema/meta.json`
|
||||
- `research/acp/spec.md`
|
||||
- `research/acp/v1-schema-to-acp-mapping.md`
|
||||
- `research/acp/friction.md`
|
||||
- `research/acp/todo.md`
|
||||
|
||||
## Change Tracking
|
||||
|
||||
- If the user asks to "push" changes, treat that as permission to commit and push all current workspace changes, not a hand-picked subset, unless the user explicitly scopes the push.
|
||||
- Keep CLI subcommands and HTTP endpoints in sync.
|
||||
- Update `docs/cli.mdx` when CLI behavior changes.
|
||||
- Regenerate `docs/openapi.json` when HTTP contracts change.
|
||||
|
|
@ -88,14 +32,6 @@
|
|||
- Append blockers/decisions to `research/acp/friction.md` during ACP work.
|
||||
- `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.
|
||||
- TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior.
|
||||
- Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests.
|
||||
|
||||
## Docker Examples (Dev Testing)
|
||||
|
||||
- When manually testing bleeding-edge (unreleased) versions of sandbox-agent in `examples/`, use `SANDBOX_AGENT_DEV=1` with the Docker-based examples.
|
||||
- This triggers `examples/shared/Dockerfile.dev` which builds the server binary from local source and packages it into the Docker image.
|
||||
- Example: `SANDBOX_AGENT_DEV=1 pnpm --filter @sandbox-agent/example-mcp start`
|
||||
|
||||
## Install Version References
|
||||
|
||||
|
|
@ -121,7 +57,7 @@
|
|||
- `.claude/commands/post-release-testing.md`
|
||||
- `examples/cloudflare/Dockerfile`
|
||||
- `examples/daytona/src/index.ts`
|
||||
- `examples/daytona/src/daytona-with-snapshot.ts`
|
||||
- `examples/shared/src/docker.ts`
|
||||
- `examples/docker/src/index.ts`
|
||||
- `examples/e2b/src/index.ts`
|
||||
- `examples/vercel/src/index.ts`
|
||||
|
|
|
|||
17
Cargo.toml
17
Cargo.toml
|
|
@ -1,9 +1,10 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["server/packages/*", "gigacode"]
|
||||
exclude = ["factory/packages/desktop/src-tauri", "foundry/packages/desktop/src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
|
||||
license = "Apache-2.0"
|
||||
|
|
@ -12,13 +13,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports
|
|||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
sandbox-agent = { version = "0.3.0", path = "server/packages/sandbox-agent" }
|
||||
sandbox-agent-error = { version = "0.3.0", path = "server/packages/error" }
|
||||
sandbox-agent-agent-management = { version = "0.3.0", path = "server/packages/agent-management" }
|
||||
sandbox-agent-agent-credentials = { version = "0.3.0", path = "server/packages/agent-credentials" }
|
||||
sandbox-agent-opencode-adapter = { version = "0.3.0", path = "server/packages/opencode-adapter" }
|
||||
sandbox-agent-opencode-server-manager = { version = "0.3.0", path = "server/packages/opencode-server-manager" }
|
||||
acp-http-adapter = { version = "0.3.0", path = "server/packages/acp-http-adapter" }
|
||||
sandbox-agent = { version = "0.3.2", path = "server/packages/sandbox-agent" }
|
||||
sandbox-agent-error = { version = "0.3.2", path = "server/packages/error" }
|
||||
sandbox-agent-agent-management = { version = "0.3.2", path = "server/packages/agent-management" }
|
||||
sandbox-agent-agent-credentials = { version = "0.3.2", path = "server/packages/agent-credentials" }
|
||||
sandbox-agent-opencode-adapter = { version = "0.3.2", path = "server/packages/opencode-adapter" }
|
||||
sandbox-agent-opencode-server-manager = { version = "0.3.2", path = "server/packages/opencode-server-manager" }
|
||||
acp-http-adapter = { version = "0.3.2", path = "server/packages/acp-http-adapter" }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -118,7 +118,6 @@ const agents = await client.listAgents();
|
|||
await client.createSession("demo", {
|
||||
agent: "codex",
|
||||
agentMode: "default",
|
||||
permissionMode: "plan",
|
||||
});
|
||||
|
||||
await client.postMessage("demo", { message: "Hello from the SDK." });
|
||||
|
|
@ -128,9 +127,7 @@ for await (const event of client.streamEvents("demo", { offset: 0 })) {
|
|||
}
|
||||
```
|
||||
|
||||
`permissionMode: "acceptEdits"` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents.
|
||||
|
||||
[SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Building a Chat UI](https://sandboxagent.dev/docs/building-chat-ui) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
|
||||
[SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
|
||||
|
||||
### HTTP Server
|
||||
|
||||
|
|
@ -146,10 +143,7 @@ sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468
|
|||
Optional: preinstall agent binaries (no server required; they will be installed lazily on first use if you skip this):
|
||||
|
||||
```bash
|
||||
sandbox-agent install-agent claude
|
||||
sandbox-agent install-agent codex
|
||||
sandbox-agent install-agent opencode
|
||||
sandbox-agent install-agent amp
|
||||
sandbox-agent install-agent --all
|
||||
```
|
||||
|
||||
To disable auth locally:
|
||||
|
|
@ -283,7 +277,7 @@ Coding agents expect interactive terminals with proper TTY handling. SSH with pi
|
|||
- **Storage of sessions on disk**: Sessions are already stored by the respective coding agents on disk. It's assumed that the consumer is streaming data from this machine to an external storage, such as Postgres, ClickHouse, or Rivet.
|
||||
- **Direct LLM wrappers**: Use the [Vercel AI SDK](https://ai-sdk.dev/docs/introduction) if you want to implement your own agent from scratch.
|
||||
- **Git Repo Management**: Just use git commands or the features provided by your sandbox provider of choice.
|
||||
- **Sandbox Provider API**: Sandbox providers have many nuanced differences in their API, it does not make sense for us to try to provide a custom layer. Instead, we opt to provide guides that let you integrate this project with sandbox providers.
|
||||
- **Sandbox Provider API**: Sandbox providers have many nuanced differences in their API, it does not make sense for us to try to provide a custom layer. Instead, we opt to provide guides that let you integrate this repository with sandbox providers.
|
||||
|
||||
## Roadmap
|
||||
|
||||
|
|
|
|||
7
biome.json
Normal file
7
biome.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 160
|
||||
}
|
||||
}
|
||||
|
|
@ -167,4 +167,4 @@ WORKDIR /home/sandbox
|
|||
EXPOSE 2468
|
||||
|
||||
ENTRYPOINT ["sandbox-agent"]
|
||||
CMD ["--host", "0.0.0.0", "--port", "2468"]
|
||||
CMD ["server", "--host", "0.0.0.0", "--port", "2468"]
|
||||
|
|
|
|||
162
docker/runtime/Dockerfile.full
Normal file
162
docker/runtime/Dockerfile.full
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# syntax=docker/dockerfile:1.10.0
|
||||
|
||||
# ============================================================================
|
||||
# Build inspector frontend
|
||||
# ============================================================================
|
||||
FROM 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/persist-indexeddb/package.json ./sdks/persist-indexeddb/
|
||||
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/persist-indexeddb ./sdks/persist-indexeddb
|
||||
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/persist-indexeddb && 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 - Uses cross-tools musl toolchain
|
||||
# ============================================================================
|
||||
FROM --platform=linux/amd64 rust:1.88.0 AS builder-amd64
|
||||
|
||||
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 && \
|
||||
cp target/x86_64-unknown-linux-musl/release/sandbox-agent /sandbox-agent
|
||||
|
||||
# ============================================================================
|
||||
# ARM64 Builder - Uses Alpine with native musl
|
||||
# ============================================================================
|
||||
FROM --platform=linux/arm64 rust:1.88-alpine AS builder-arm64
|
||||
|
||||
RUN apk add --no-cache \
|
||||
musl-dev \
|
||||
clang \
|
||||
llvm-dev \
|
||||
openssl-dev \
|
||||
openssl-libs-static \
|
||||
pkgconfig \
|
||||
git \
|
||||
curl \
|
||||
build-base
|
||||
|
||||
RUN rustup target add aarch64-unknown-linux-musl
|
||||
|
||||
ENV CARGO_INCREMENTAL=0 \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
RUSTFLAGS="-C target-feature=+crt-static"
|
||||
|
||||
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 aarch64-unknown-linux-musl && \
|
||||
cp target/aarch64-unknown-linux-musl/release/sandbox-agent /sandbox-agent
|
||||
|
||||
# ============================================================================
|
||||
# Select the appropriate builder based on target architecture
|
||||
# ============================================================================
|
||||
ARG TARGETARCH
|
||||
FROM builder-${TARGETARCH} AS builder
|
||||
|
||||
# Runtime stage - full image with all supported agents preinstalled
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
|
||||
RUN chmod +x /usr/local/bin/sandbox-agent
|
||||
|
||||
RUN useradd -m -s /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
RUN sandbox-agent install-agent --all
|
||||
|
||||
EXPOSE 2468
|
||||
|
||||
ENTRYPOINT ["sandbox-agent"]
|
||||
CMD ["server", "--host", "0.0.0.0", "--port", "2468"]
|
||||
|
|
@ -125,9 +125,45 @@ for (const opt of options) {
|
|||
await session.setConfigOption("some-agent-option", "value");
|
||||
```
|
||||
|
||||
## Handle permission requests
|
||||
|
||||
For agents that request tool-use permissions, register a permission listener and reply with `once`, `always`, or `reject`:
|
||||
|
||||
```ts
|
||||
const session = await sdk.createSession({
|
||||
agent: "claude",
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
session.onPermissionRequest((request) => {
|
||||
console.log(request.toolCall.title, request.availableReplies);
|
||||
void session.respondPermission(request.id, "once");
|
||||
});
|
||||
|
||||
await session.prompt([
|
||||
{ type: "text", text: "Create ./permission-example.txt with the text hello." },
|
||||
]);
|
||||
```
|
||||
|
||||
|
||||
### Auto-approving permissions
|
||||
|
||||
To auto-approve all permission requests, respond with `"once"` or `"always"` in your listener:
|
||||
|
||||
```ts
|
||||
session.onPermissionRequest((request) => {
|
||||
void session.respondPermission(request.id, "always");
|
||||
});
|
||||
```
|
||||
|
||||
See `examples/permissions/src/index.ts` for a complete permissions example that works with Claude and Codex.
|
||||
|
||||
<Info>
|
||||
Some agents like Claude allow configuring permission behavior through modes (e.g. `bypassPermissions`, `acceptEdits`). We recommend leaving the mode as `default` and handling permission decisions explicitly in `onPermissionRequest` instead.
|
||||
</Info>
|
||||
|
||||
## Destroy a session
|
||||
|
||||
```ts
|
||||
await sdk.destroySession(session.id);
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -58,4 +58,4 @@ Use the filesystem API to upload files, then include file references in prompt c
|
|||
|
||||
- Use absolute file URIs in `resource_link` blocks.
|
||||
- If `mimeType` is omitted, the agent/runtime may infer a default.
|
||||
- Support for non-text resources depends on each agent's ACP prompt capabilities.
|
||||
- Support for non-text resources depends on each agent's prompt capabilities.
|
||||
|
|
|
|||
|
|
@ -1,370 +0,0 @@
|
|||
---
|
||||
title: "Building a Chat UI"
|
||||
description: "Build a chat interface using the universal event stream."
|
||||
icon: "comments"
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### List agents
|
||||
|
||||
```ts
|
||||
const { agents } = await client.listAgents();
|
||||
|
||||
// Each agent exposes feature coverage via `capabilities` to determine what UI to show
|
||||
const claude = agents.find((a) => a.id === "claude");
|
||||
if (claude?.capabilities.permissions) {
|
||||
// Show permission approval UI
|
||||
}
|
||||
if (claude?.capabilities.questions) {
|
||||
// Show question response UI
|
||||
}
|
||||
```
|
||||
|
||||
### Create a session
|
||||
|
||||
```ts
|
||||
const sessionId = `session-${crypto.randomUUID()}`;
|
||||
|
||||
await client.createSession(sessionId, {
|
||||
agent: "claude",
|
||||
agentMode: "code", // Optional: agent-specific mode
|
||||
permissionMode: "default", // Optional: "default" | "plan" | "bypass" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default)
|
||||
model: "claude-sonnet-4", // Optional: model override
|
||||
});
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```ts
|
||||
await client.postMessage(sessionId, { message: "Hello, world!" });
|
||||
```
|
||||
|
||||
### Stream events
|
||||
|
||||
Three options for receiving events:
|
||||
|
||||
```ts
|
||||
// Option 1: SSE (recommended for real-time UI)
|
||||
const stream = client.streamEvents(sessionId, { offset: 0 });
|
||||
for await (const event of stream) {
|
||||
handleEvent(event);
|
||||
}
|
||||
|
||||
// Option 2: Polling
|
||||
const { events, hasMore } = await client.getEvents(sessionId, { offset: 0 });
|
||||
events.forEach(handleEvent);
|
||||
|
||||
// Option 3: Turn streaming (send + stream in one call)
|
||||
const stream = client.streamTurn(sessionId, { message: "Hello" });
|
||||
for await (const event of stream) {
|
||||
handleEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
Use `offset` to track the last seen `sequence` number and resume from where you left off.
|
||||
|
||||
---
|
||||
|
||||
## Handling Events
|
||||
|
||||
### Bare minimum
|
||||
|
||||
Handle item lifecycle plus turn lifecycle to render a basic chat:
|
||||
|
||||
```ts
|
||||
type ItemState = {
|
||||
item: UniversalItem;
|
||||
deltas: string[];
|
||||
};
|
||||
|
||||
const items = new Map<string, ItemState>();
|
||||
let turnInProgress = false;
|
||||
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
switch (event.type) {
|
||||
case "turn.started": {
|
||||
turnInProgress = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case "turn.ended": {
|
||||
turnInProgress = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case "item.started": {
|
||||
const { item } = event.data as ItemEventData;
|
||||
items.set(item.item_id, { item, deltas: [] });
|
||||
break;
|
||||
}
|
||||
|
||||
case "item.delta": {
|
||||
const { item_id, delta } = event.data as ItemDeltaData;
|
||||
const state = items.get(item_id);
|
||||
if (state) {
|
||||
state.deltas.push(delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "item.completed": {
|
||||
const { item } = event.data as ItemEventData;
|
||||
const state = items.get(item.item_id);
|
||||
if (state) {
|
||||
state.item = item;
|
||||
state.deltas = []; // Clear deltas, use final content
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When rendering:
|
||||
- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.).
|
||||
- Use `item.status === "in_progress"` for per-item streaming state.
|
||||
|
||||
```ts
|
||||
function renderItem(state: ItemState) {
|
||||
const { item, deltas } = state;
|
||||
const isItemLoading = item.status === "in_progress";
|
||||
|
||||
// For streaming text, combine item content with accumulated deltas
|
||||
const text = item.content
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
const streamedText = text + deltas.join("");
|
||||
|
||||
return {
|
||||
content: streamedText,
|
||||
isItemLoading,
|
||||
isTurnLoading: turnInProgress,
|
||||
role: item.role,
|
||||
kind: item.kind,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Extra events
|
||||
|
||||
Handle these for a complete implementation:
|
||||
|
||||
```ts
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
switch (event.type) {
|
||||
// ... bare minimum events above ...
|
||||
|
||||
case "session.started": {
|
||||
// Session is ready
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.ended": {
|
||||
const { reason, terminated_by } = event.data as SessionEndedData;
|
||||
// Disable input, show end reason
|
||||
// reason: "completed" | "error" | "terminated"
|
||||
// terminated_by: "agent" | "daemon"
|
||||
break;
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const { message, code } = event.data as ErrorData;
|
||||
// Display error to user
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent.unparsed": {
|
||||
const { error, location } = event.data as AgentUnparsedData;
|
||||
// Parsing failure - treat as bug in development
|
||||
console.error(`Parse error at ${location}: ${error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Content parts
|
||||
|
||||
Each item has `content` parts. Render based on `type`:
|
||||
|
||||
```ts
|
||||
function renderContentPart(part: ContentPart) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return <Markdown>{part.text}</Markdown>;
|
||||
|
||||
case "tool_call":
|
||||
return <ToolCall name={part.name} args={part.arguments} />;
|
||||
|
||||
case "tool_result":
|
||||
return <ToolResult output={part.output} />;
|
||||
|
||||
case "file_ref":
|
||||
return <FileChange path={part.path} action={part.action} diff={part.diff} />;
|
||||
|
||||
case "reasoning":
|
||||
return <Reasoning>{part.text}</Reasoning>;
|
||||
|
||||
case "status":
|
||||
return <Status label={part.label} detail={part.detail} />;
|
||||
|
||||
case "image":
|
||||
return <Image src={part.path} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handling Permissions
|
||||
|
||||
When `permission.requested` arrives, show an approval UI:
|
||||
|
||||
```ts
|
||||
const pendingPermissions = new Map<string, PermissionEventData>();
|
||||
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
if (event.type === "permission.requested") {
|
||||
const data = event.data as PermissionEventData;
|
||||
pendingPermissions.set(data.permission_id, data);
|
||||
}
|
||||
|
||||
if (event.type === "permission.resolved") {
|
||||
const data = event.data as PermissionEventData;
|
||||
pendingPermissions.delete(data.permission_id);
|
||||
}
|
||||
}
|
||||
|
||||
// User clicks approve/deny
|
||||
async function replyPermission(id: string, reply: "once" | "always" | "reject") {
|
||||
await client.replyPermission(sessionId, id, { reply });
|
||||
pendingPermissions.delete(id);
|
||||
}
|
||||
```
|
||||
|
||||
Render permission requests:
|
||||
|
||||
```ts
|
||||
function PermissionRequest({ data }: { data: PermissionEventData }) {
|
||||
return (
|
||||
<div>
|
||||
<p>Allow: {data.action}</p>
|
||||
<button onClick={() => replyPermission(data.permission_id, "once")}>
|
||||
Allow Once
|
||||
</button>
|
||||
<button onClick={() => replyPermission(data.permission_id, "always")}>
|
||||
Always Allow
|
||||
</button>
|
||||
<button onClick={() => replyPermission(data.permission_id, "reject")}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handling Questions
|
||||
|
||||
When `question.requested` arrives, show a selection UI:
|
||||
|
||||
```ts
|
||||
const pendingQuestions = new Map<string, QuestionEventData>();
|
||||
|
||||
function handleEvent(event: UniversalEvent) {
|
||||
if (event.type === "question.requested") {
|
||||
const data = event.data as QuestionEventData;
|
||||
pendingQuestions.set(data.question_id, data);
|
||||
}
|
||||
|
||||
if (event.type === "question.resolved") {
|
||||
const data = event.data as QuestionEventData;
|
||||
pendingQuestions.delete(data.question_id);
|
||||
}
|
||||
}
|
||||
|
||||
// User selects answer(s)
|
||||
async function answerQuestion(id: string, answers: string[][]) {
|
||||
await client.replyQuestion(sessionId, id, { answers });
|
||||
pendingQuestions.delete(id);
|
||||
}
|
||||
|
||||
async function rejectQuestion(id: string) {
|
||||
await client.rejectQuestion(sessionId, id);
|
||||
pendingQuestions.delete(id);
|
||||
}
|
||||
```
|
||||
|
||||
Render question requests:
|
||||
|
||||
```ts
|
||||
function QuestionRequest({ data }: { data: QuestionEventData }) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{data.prompt}</p>
|
||||
{data.options.map((option) => (
|
||||
<label key={option}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(option)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelected([...selected, option]);
|
||||
} else {
|
||||
setSelected(selected.filter((s) => s !== option));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
<button onClick={() => answerQuestion(data.question_id, [selected])}>
|
||||
Submit
|
||||
</button>
|
||||
<button onClick={() => rejectQuestion(data.question_id)}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing with Mock Agent
|
||||
|
||||
The `mock` agent lets you test UI behaviors without external credentials:
|
||||
|
||||
```ts
|
||||
await client.createSession("test-session", { agent: "mock" });
|
||||
```
|
||||
|
||||
Send `help` to see available commands:
|
||||
|
||||
| Command | Tests |
|
||||
|---------|-------|
|
||||
| `help` | Lists all commands |
|
||||
| `demo` | Full UI coverage sequence with markers |
|
||||
| `markdown` | Streaming markdown rendering |
|
||||
| `tool` | Tool call + result with file refs |
|
||||
| `status` | Status item updates |
|
||||
| `image` | Image content part |
|
||||
| `permission` | Permission request flow |
|
||||
| `question` | Question request flow |
|
||||
| `error` | Error + unparsed events |
|
||||
| `end` | Session ended event |
|
||||
| `echo <text>` | Echo text as assistant message |
|
||||
|
||||
Any unrecognized text is echoed back as an assistant message.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
The [Inspector UI](https://github.com/rivet-dev/sandbox-agent/blob/main/frontend/packages/inspector/src/App.tsx)
|
||||
is a complete reference showing session management, event rendering, and HITL flows.
|
||||
14
docs/cli.mdx
14
docs/cli.mdx
|
|
@ -39,20 +39,24 @@ Notes:
|
|||
|
||||
## install-agent
|
||||
|
||||
Install or reinstall a single agent.
|
||||
Install or reinstall a single agent, or every supported agent with `--all`.
|
||||
|
||||
```bash
|
||||
sandbox-agent install-agent <AGENT> [OPTIONS]
|
||||
sandbox-agent install-agent [<AGENT>] [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all` | Install every supported agent |
|
||||
| `-r, --reinstall` | Force reinstall |
|
||||
| `--agent-version <VERSION>` | Override agent package version |
|
||||
| `--agent-process-version <VERSION>` | Override agent process version |
|
||||
| `--agent-version <VERSION>` | Override agent package version (conflicts with `--all`) |
|
||||
| `--agent-process-version <VERSION>` | Override agent process version (conflicts with `--all`) |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
sandbox-agent install-agent claude --reinstall
|
||||
sandbox-agent install-agent --all
|
||||
```
|
||||
|
||||
### Custom Pi implementation path
|
||||
|
|
@ -214,7 +218,7 @@ sandbox-agent api agents list
|
|||
|
||||
#### api agents report
|
||||
|
||||
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
|
||||
Emit a JSON report of available models, modes, and thought levels for every agent, grouped by category.
|
||||
|
||||
```bash
|
||||
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
---
|
||||
title: "Credentials"
|
||||
description: "How Sandbox Agent discovers and uses provider credentials."
|
||||
---
|
||||
|
||||
Sandbox Agent discovers API credentials from environment variables and local agent config files.
|
||||
These credentials are passed through to underlying agent runtimes.
|
||||
|
||||
## Credential sources
|
||||
|
||||
Credentials are discovered in priority order.
|
||||
|
||||
### Environment variables (highest priority)
|
||||
|
||||
API keys first:
|
||||
|
||||
| Variable | Provider |
|
||||
|----------|----------|
|
||||
| `ANTHROPIC_API_KEY` | Anthropic |
|
||||
| `CLAUDE_API_KEY` | Anthropic fallback |
|
||||
| `OPENAI_API_KEY` | OpenAI |
|
||||
| `CODEX_API_KEY` | OpenAI fallback |
|
||||
|
||||
OAuth tokens (used when OAuth extraction is enabled):
|
||||
|
||||
| Variable | Provider |
|
||||
|----------|----------|
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Anthropic |
|
||||
| `ANTHROPIC_AUTH_TOKEN` | Anthropic fallback |
|
||||
|
||||
### Agent config files
|
||||
|
||||
| Agent | Config path | Provider |
|
||||
|-------|-------------|----------|
|
||||
| Amp | `~/.amp/config.json` | Anthropic |
|
||||
| Claude Code | `~/.claude.json`, `~/.claude/.credentials.json` | Anthropic |
|
||||
| Codex | `~/.codex/auth.json` | OpenAI |
|
||||
| OpenCode | `~/.local/share/opencode/auth.json` | Anthropic/OpenAI |
|
||||
|
||||
## Provider requirements by agent
|
||||
|
||||
| Agent | Required provider |
|
||||
|-------|-------------------|
|
||||
| Claude Code | Anthropic |
|
||||
| Amp | Anthropic |
|
||||
| Codex | OpenAI |
|
||||
| OpenCode | Anthropic or OpenAI |
|
||||
| Mock | None |
|
||||
|
||||
## Error handling behavior
|
||||
|
||||
Credential extraction is best-effort:
|
||||
|
||||
- Missing or malformed files are skipped.
|
||||
- Discovery continues to later sources.
|
||||
- Missing credentials mark providers unavailable instead of failing server startup.
|
||||
|
||||
When prompting, Sandbox Agent does not pre-validate provider credentials. Agent-native authentication errors surface through session events/output.
|
||||
|
||||
## Checking credential status
|
||||
|
||||
### API
|
||||
|
||||
`GET /v1/agents` includes `credentialsAvailable` per agent.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"id": "claude",
|
||||
"installed": true,
|
||||
"credentialsAvailable": true
|
||||
},
|
||||
{
|
||||
"id": "codex",
|
||||
"installed": true,
|
||||
"credentialsAvailable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript SDK
|
||||
|
||||
```typescript
|
||||
const result = await sdk.listAgents();
|
||||
|
||||
for (const agent of result.agents) {
|
||||
console.log(`${agent.id}: ${agent.credentialsAvailable ? "authenticated" : "no credentials"}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Passing credentials explicitly
|
||||
|
||||
Set environment variables before starting Sandbox Agent:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
export OPENAI_API_KEY=sk-...
|
||||
sandbox-agent daemon start
|
||||
```
|
||||
|
||||
Or with SDK-managed local spawn:
|
||||
|
||||
```typescript
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: process.env.MY_ANTHROPIC_KEY,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
|
@ -115,8 +115,8 @@ This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path an
|
|||
## Troubleshooting streaming updates
|
||||
|
||||
If you only receive:
|
||||
- outbound `session/prompt`
|
||||
- final `{ stopReason: "end_turn" }`
|
||||
- the outbound prompt request
|
||||
- the final `{ stopReason: "end_turn" }` response
|
||||
|
||||
then the streamed update channel dropped. In Cloudflare sandbox paths, this is typically caused by forwarding `AbortSignal` from SDK fetch init into `containerFetch(...)`.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@ Docker is not recommended for production isolation of untrusted workloads. Use d
|
|||
|
||||
## Quick start
|
||||
|
||||
Run Sandbox Agent with agents pre-installed:
|
||||
Run the published full image with all supported agents pre-installed:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 3000:3000 \
|
||||
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
alpine:latest sh -c "\
|
||||
apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \
|
||||
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 3000"
|
||||
rivetdev/sandbox-agent:0.3.1-full \
|
||||
server --no-token --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
The `0.3.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image.
|
||||
|
||||
## TypeScript with dockerode
|
||||
|
||||
```typescript
|
||||
|
|
@ -31,14 +31,8 @@ const docker = new Docker();
|
|||
const PORT = 3000;
|
||||
|
||||
const container = await docker.createContainer({
|
||||
Image: "node:22-bookworm-slim",
|
||||
Cmd: ["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.3.x/install.sh | sh",
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||
].join(" && ")],
|
||||
Image: "rivetdev/sandbox-agent:0.3.1-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}`,
|
||||
|
|
@ -60,6 +54,29 @@ const session = await sdk.createSession({ agent: "codex" });
|
|||
await session.prompt([{ type: "text", text: "Summarize this repository." }]);
|
||||
```
|
||||
|
||||
## Building a custom image with everything preinstalled
|
||||
|
||||
If you need to extend your own base image, install Sandbox Agent and preinstall every supported agent in one step:
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
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 && \
|
||||
sandbox-agent install-agent --all
|
||||
|
||||
RUN useradd -m -s /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
EXPOSE 2468
|
||||
ENTRYPOINT ["sandbox-agent"]
|
||||
CMD ["server", "--host", "0.0.0.0", "--port", "2468"]
|
||||
```
|
||||
|
||||
## Building from source
|
||||
|
||||
```bash
|
||||
|
|
|
|||
155
docs/deploy/foundry-self-hosting.mdx
Normal file
155
docs/deploy/foundry-self-hosting.mdx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
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://<your-backend-host>/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.
|
||||
97
docs/deploy/modal.mdx
Normal file
97
docs/deploy/modal.mdx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
title: "Modal"
|
||||
description: "Deploy Sandbox Agent inside a Modal sandbox."
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` from [modal.com/settings](https://modal.com/settings)
|
||||
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
|
||||
|
||||
## TypeScript example
|
||||
|
||||
```typescript
|
||||
import { ModalClient } from "modal";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
const modal = new ModalClient();
|
||||
const app = await modal.apps.fromName("sandbox-agent", { createIfMissing: true });
|
||||
|
||||
const image = modal.images
|
||||
.fromRegistry("ubuntu:22.04")
|
||||
.dockerfileCommands([
|
||||
"RUN apt-get update && apt-get install -y curl ca-certificates",
|
||||
"RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
]);
|
||||
|
||||
const envs: Record<string, string> = {};
|
||||
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 secrets = Object.keys(envs).length > 0
|
||||
? [await modal.secrets.fromObject(envs)]
|
||||
: [];
|
||||
|
||||
const sb = await modal.sandboxes.create(app, image, {
|
||||
encryptedPorts: [3000],
|
||||
secrets,
|
||||
});
|
||||
|
||||
const exec = async (cmd: string) => {
|
||||
const p = await sb.exec(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe" });
|
||||
const exitCode = await p.wait();
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await p.stderr.readText();
|
||||
throw new Error(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`);
|
||||
}
|
||||
};
|
||||
|
||||
await exec("sandbox-agent install-agent claude");
|
||||
await exec("sandbox-agent install-agent codex");
|
||||
|
||||
await sb.exec(
|
||||
["bash", "-c", "sandbox-agent server --no-token --host 0.0.0.0 --port 3000 &"],
|
||||
);
|
||||
|
||||
const tunnels = await sb.tunnels();
|
||||
const baseUrl = tunnels[3000].url;
|
||||
|
||||
const sdk = await SandboxAgent.connect({ baseUrl });
|
||||
|
||||
const session = await sdk.createSession({ agent: "claude" });
|
||||
const off = session.onEvent((event) => {
|
||||
console.log(event.sender, event.payload);
|
||||
});
|
||||
|
||||
await session.prompt([{ type: "text", text: "Summarize this repository" }]);
|
||||
off();
|
||||
|
||||
await sb.terminate();
|
||||
```
|
||||
|
||||
## Faster cold starts
|
||||
|
||||
Modal caches image layers, so the `dockerfileCommands` that install `curl` and `sandbox-agent` only run on the first build. Subsequent sandbox creates reuse the cached image.
|
||||
|
||||
## Running the test
|
||||
|
||||
The example includes a health-check test. First, build the SDK:
|
||||
|
||||
```bash
|
||||
pnpm --filter sandbox-agent build
|
||||
```
|
||||
|
||||
Then run the test with your Modal credentials:
|
||||
|
||||
```bash
|
||||
MODAL_TOKEN_ID=<your-token-id> MODAL_TOKEN_SECRET=<your-token-secret> npx vitest run
|
||||
```
|
||||
|
||||
Run from `examples/modal/`. The test will skip if credentials are not set.
|
||||
|
||||
## Notes
|
||||
|
||||
- Modal sandboxes use [gVisor](https://gvisor.dev/) for strong isolation.
|
||||
- Ports are exposed via encrypted tunnels (`encryptedPorts`). Use `sb.tunnels()` to get the public HTTPS URL.
|
||||
- Environment variables (API keys) are passed as Modal [Secrets](https://modal.com/docs/guide/secrets) rather than plain env vars for security.
|
||||
- Always call `sb.terminate()` when done to avoid leaking sandbox resources.
|
||||
246
docs/docs.json
246
docs/docs.json
|
|
@ -1,131 +1,119 @@
|
|||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "willow",
|
||||
"name": "Sandbox Agent SDK",
|
||||
"appearance": {
|
||||
"default": "dark",
|
||||
"strict": true
|
||||
},
|
||||
"colors": {
|
||||
"primary": "#ff4f00",
|
||||
"light": "#ff4f00",
|
||||
"dark": "#ff4f00"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"logo": {
|
||||
"light": "/logo/light.svg",
|
||||
"dark": "/logo/dark.svg"
|
||||
},
|
||||
"integrations": {
|
||||
"posthog": {
|
||||
"apiKey": "phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo",
|
||||
"apiHost": "https://ph.rivet.gg",
|
||||
"sessionRecording": true
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"href": "https://github.com/rivet-dev/sandbox-agent"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Documentation",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Getting started",
|
||||
"pages": [
|
||||
"quickstart",
|
||||
"sdk-overview",
|
||||
"react-components",
|
||||
{
|
||||
"group": "Deploy",
|
||||
"icon": "server",
|
||||
"pages": [
|
||||
"deploy/local",
|
||||
"deploy/computesdk",
|
||||
"deploy/e2b",
|
||||
"deploy/daytona",
|
||||
"deploy/vercel",
|
||||
"deploy/cloudflare",
|
||||
"deploy/docker",
|
||||
"deploy/boxlite"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Agent",
|
||||
"pages": [
|
||||
"agent-sessions",
|
||||
"attachments",
|
||||
"skills-config",
|
||||
"mcp-config",
|
||||
"custom-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "System",
|
||||
"pages": ["file-system", "processes"]
|
||||
},
|
||||
{
|
||||
"group": "Orchestration",
|
||||
"pages": [
|
||||
"architecture",
|
||||
"session-persistence",
|
||||
"observability",
|
||||
"multiplayer",
|
||||
"security"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"agent-capabilities",
|
||||
"cli",
|
||||
"inspector",
|
||||
"opencode-compatibility",
|
||||
{
|
||||
"group": "More",
|
||||
"pages": [
|
||||
"credentials",
|
||||
"daemon",
|
||||
"cors",
|
||||
"session-restoration",
|
||||
"telemetry",
|
||||
{
|
||||
"group": "AI",
|
||||
"pages": ["ai/skill", "ai/llms-txt"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "HTTP API",
|
||||
"pages": [
|
||||
{
|
||||
"group": "HTTP Reference",
|
||||
"openapi": "openapi.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "willow",
|
||||
"name": "Sandbox Agent SDK",
|
||||
"appearance": {
|
||||
"default": "dark",
|
||||
"strict": true
|
||||
},
|
||||
"colors": {
|
||||
"primary": "#ff4f00",
|
||||
"light": "#ff4f00",
|
||||
"dark": "#ff4f00"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"logo": {
|
||||
"light": "/logo/light.svg",
|
||||
"dark": "/logo/dark.svg"
|
||||
},
|
||||
"integrations": {
|
||||
"posthog": {
|
||||
"apiKey": "phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo",
|
||||
"apiHost": "https://ph.rivet.gg",
|
||||
"sessionRecording": true
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"href": "https://github.com/rivet-dev/sandbox-agent"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Documentation",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Getting started",
|
||||
"pages": [
|
||||
"quickstart",
|
||||
"sdk-overview",
|
||||
"llm-credentials",
|
||||
"react-components",
|
||||
{
|
||||
"group": "Deploy",
|
||||
"icon": "server",
|
||||
"pages": [
|
||||
"deploy/local",
|
||||
"deploy/computesdk",
|
||||
"deploy/e2b",
|
||||
"deploy/daytona",
|
||||
"deploy/vercel",
|
||||
"deploy/cloudflare",
|
||||
"deploy/docker",
|
||||
"deploy/boxlite"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Agent",
|
||||
"pages": ["agent-sessions", "attachments", "skills-config", "mcp-config", "custom-tools"]
|
||||
},
|
||||
{
|
||||
"group": "System",
|
||||
"pages": ["file-system", "processes"]
|
||||
},
|
||||
{
|
||||
"group": "Orchestration",
|
||||
"pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"]
|
||||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"agent-capabilities",
|
||||
"cli",
|
||||
"inspector",
|
||||
"opencode-compatibility",
|
||||
{
|
||||
"group": "More",
|
||||
"pages": [
|
||||
"daemon",
|
||||
"cors",
|
||||
"session-restoration",
|
||||
"telemetry",
|
||||
{
|
||||
"group": "AI",
|
||||
"pages": ["ai/skill", "ai/llms-txt"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "HTTP API",
|
||||
"pages": [
|
||||
{
|
||||
"group": "HTTP Reference",
|
||||
"openapi": "openapi.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ console.log(url);
|
|||
- Event JSON inspector
|
||||
- Prompt testing
|
||||
- Request/response debugging
|
||||
- Interactive permission prompts (approve, always-allow, or reject tool-use requests)
|
||||
- Process management (create, stop, kill, delete, view logs)
|
||||
- Interactive PTY terminal for tty processes
|
||||
- One-shot command execution
|
||||
|
|
|
|||
250
docs/llm-credentials.mdx
Normal file
250
docs/llm-credentials.mdx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
---
|
||||
title: "LLM Credentials"
|
||||
description: "Strategies for providing LLM provider credentials to agents."
|
||||
icon: "key"
|
||||
---
|
||||
|
||||
Sandbox Agent needs LLM provider credentials (Anthropic, OpenAI, etc.) to run agent sessions.
|
||||
|
||||
## Configuration
|
||||
|
||||
Pass credentials via `spawn.env` when starting a sandbox. Each call to `SandboxAgent.start()` can use different credentials:
|
||||
|
||||
```typescript
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-ant-...",
|
||||
OPENAI_API_KEY: "sk-...",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Each agent requires credentials from a specific provider. Sandbox Agent checks environment variables (including those passed via `spawn.env`) and host config files:
|
||||
|
||||
| Agent | Provider | Environment variables | Config files |
|
||||
|-------|----------|----------------------|--------------|
|
||||
| Claude Code | Anthropic | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | `~/.claude.json`, `~/.claude/.credentials.json` |
|
||||
| Amp | Anthropic | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | `~/.amp/config.json` |
|
||||
| Codex | OpenAI | `OPENAI_API_KEY`, `CODEX_API_KEY` | `~/.codex/auth.json` |
|
||||
| OpenCode | Anthropic or OpenAI | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | `~/.local/share/opencode/auth.json` |
|
||||
| Mock | None | - | - |
|
||||
|
||||
## Credential strategies
|
||||
|
||||
LLM credentials are passed into the sandbox as environment variables. The agent and everything inside the sandbox has access to the token, so it's important to choose the right strategy for how you provision and scope these credentials.
|
||||
|
||||
| Strategy | Who pays | Cost attribution | Best for |
|
||||
|----------|----------|-----------------|----------|
|
||||
| **Per-tenant gateway** (recommended) | Your organization, billed back per tenant | Per-tenant keys with budgets | Multi-tenant SaaS, usage-based billing |
|
||||
| **Bring your own key** | Each user (usage-based) | Per-user by default | Dev environments, internal tools |
|
||||
| **Shared API key** | Your organization | None (single bill) | Single-tenant apps, internal platforms |
|
||||
| **Personal subscription** | Each user (existing subscription) | Per-user by default | Local dev, internal tools where users have Claude or Codex subscriptions |
|
||||
|
||||
### Per-tenant gateway (recommended)
|
||||
|
||||
Route LLM traffic through a gateway that mints per-tenant API keys, each with its own spend tracking and budget limits.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
B[Your Backend] -->|tenant key| S[Sandbox]
|
||||
S -->|LLM requests| G[Gateway]
|
||||
G -->|scoped key| P[LLM Provider]
|
||||
```
|
||||
|
||||
Your backend issues a scoped key per tenant, then passes it to the sandbox. This is the typical pattern when using sandbox providers (E2B, Daytona, Docker).
|
||||
|
||||
```typescript expandable
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
async function createTenantSandbox(tenantId: string) {
|
||||
// Issue a scoped key for this tenant via OpenRouter
|
||||
const res = await fetch("https://openrouter.ai/api/v1/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_PROVISIONING_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `tenant-${tenantId}`,
|
||||
limit: 50,
|
||||
limitResetType: "monthly",
|
||||
}),
|
||||
});
|
||||
const { key } = await res.json();
|
||||
|
||||
// Start a sandbox with the tenant's scoped key
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
env: {
|
||||
OPENAI_API_KEY: key, // OpenRouter uses OpenAI-compatible endpoints
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({
|
||||
agent: "claude",
|
||||
sessionInit: { cwd: "/workspace" },
|
||||
});
|
||||
|
||||
return { sdk, session };
|
||||
}
|
||||
```
|
||||
|
||||
#### Security
|
||||
|
||||
Recommended for multi-tenant applications. Each tenant gets a scoped key with its own budget, so exfiltration only exposes that tenant's allowance.
|
||||
|
||||
#### Use cases
|
||||
|
||||
- **Multi-tenant SaaS**: per-tenant spend tracking and budget limits
|
||||
- **Production apps**: exposed to end users who need isolated credentials
|
||||
- **Usage-based billing**: each tenant pays for their own consumption
|
||||
|
||||
#### Choosing a gateway
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="OpenRouter provisioned keys" icon="cloud">
|
||||
|
||||
Managed service, zero infrastructure. [OpenRouter](https://openrouter.ai/docs/features/provisioning-api-keys) provides per-tenant API keys with spend tracking and budget limits via their Provisioning API. Pass the tenant key to Sandbox Agent as `OPENAI_API_KEY` (OpenRouter uses OpenAI-compatible endpoints).
|
||||
|
||||
```bash
|
||||
# Create a key for a tenant with a $50/month budget
|
||||
curl https://openrouter.ai/api/v1/keys \
|
||||
-H "Authorization: Bearer $PROVISIONING_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "tenant-acme",
|
||||
"limit": 50,
|
||||
"limitResetType": "monthly"
|
||||
}'
|
||||
```
|
||||
|
||||
Easiest to set up but not open-source. See [OpenRouter pricing](https://openrouter.ai/docs/framework/pricing) for details.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="LiteLLM proxy" icon="server">
|
||||
|
||||
Self-hosted, open-source (MIT). [LiteLLM](https://github.com/BerriAI/litellm) is an OpenAI-compatible proxy with hierarchical budgets (org, team, user, key), virtual keys, and spend tracking. Requires Python + PostgreSQL.
|
||||
|
||||
```bash
|
||||
# Create a team (tenant) with a $500 budget
|
||||
curl http://litellm:4000/team/new \
|
||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_alias": "tenant-acme",
|
||||
"max_budget": 500
|
||||
}'
|
||||
|
||||
# Generate a key for that team
|
||||
curl http://litellm:4000/key/generate \
|
||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"team_id": "team-abc123",
|
||||
"max_budget": 100
|
||||
}'
|
||||
```
|
||||
|
||||
Full control with no vendor lock-in. Organization-level features require an enterprise license.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Portkey gateway" icon="code-branch">
|
||||
|
||||
Self-hosted, open-source (Apache 2.0). [Portkey](https://github.com/Portkey-AI/gateway) is a lightweight OpenAI-compatible gateway supporting 200+ providers. Single binary, no database required. Create virtual keys with per-tenant budget limits and pass them to Sandbox Agent.
|
||||
|
||||
Lightest operational footprint of the self-hosted options. Observability and analytics require the managed platform or your own tooling.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
To bill tenants for LLM usage, use [Stripe token billing](https://docs.stripe.com/billing/token-billing) (integrates natively with OpenRouter) or query your gateway's spend API and feed usage into your billing system.
|
||||
|
||||
### Bring your own key
|
||||
|
||||
Each user provides their own API key. Users are billed directly by the LLM provider with no additional infrastructure needed.
|
||||
|
||||
Pass the user's key via `spawn.env`:
|
||||
|
||||
```typescript
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: userProvidedKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Security
|
||||
|
||||
API keys are typically long-lived. The key is visible to the agent and anything running inside the sandbox, so exfiltration is possible. This is usually acceptable for developer-facing tools where the user owns the key.
|
||||
|
||||
#### Use cases
|
||||
|
||||
- **Developer tools**: each user manages their own API key
|
||||
- **Internal platforms**: users already have LLM provider accounts
|
||||
- **Per-user billing**: no extra infrastructure needed
|
||||
|
||||
### Shared credentials
|
||||
|
||||
A single organization-wide API key is used for all sessions. All token usage appears on one bill with no per-user or per-tenant cost attribution.
|
||||
|
||||
```typescript
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: process.env.ORG_ANTHROPIC_KEY!,
|
||||
OPENAI_API_KEY: process.env.ORG_OPENAI_KEY!,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you need to track or limit spend per tenant, use a per-tenant gateway instead.
|
||||
|
||||
#### Security
|
||||
|
||||
Not recommended for anything other than internal tooling. A single exfiltrated key exposes your organization's entire LLM budget. If you need org-paid credentials for external users, use a per-tenant gateway with scoped keys instead.
|
||||
|
||||
#### Use cases
|
||||
|
||||
- **Single-tenant apps**: small number of users, one bill
|
||||
- **Prototyping**: cost attribution not needed yet
|
||||
- **Simplicity over security**: acceptable when exfiltration risk is low
|
||||
|
||||
### Personal subscription
|
||||
|
||||
If the user is signed into Claude Code or Codex on the host machine, Sandbox Agent automatically picks up their OAuth tokens. No configuration is needed.
|
||||
|
||||
#### Remote sandboxes
|
||||
|
||||
Extract credentials locally and pass them to a remote sandbox via `spawn.env`:
|
||||
|
||||
```bash
|
||||
$ sandbox-agent credentials extract-env
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
CLAUDE_API_KEY=sk-ant-...
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Use `-e` to prefix with `export` for shell sourcing.
|
||||
|
||||
#### Security
|
||||
|
||||
Personal subscriptions use OAuth tokens with a limited lifespan. These are the same credentials used when running an agent normally on the host. If a token is exfiltrated from the sandbox, the exposure window is short.
|
||||
|
||||
#### Use cases
|
||||
|
||||
- **Local development**: users are already signed into Claude Code or Codex
|
||||
- **Internal tools**: every user has their own subscription
|
||||
- **Prototyping**: no key management needed
|
||||
|
|
@ -6,8 +6,6 @@ icon: "database"
|
|||
|
||||
Sandbox Agent stores sessions in memory only. When the server restarts or the sandbox is destroyed, all session data is lost. It's your responsibility to persist events to your own database.
|
||||
|
||||
See the [Building a Chat UI](/building-chat-ui) guide for understanding session lifecycle events like `session.started` and `session.ended`.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
1. Store events to your database as they arrive
|
||||
|
|
@ -18,11 +16,11 @@ This prevents duplicate writes and lets you recover from disconnects.
|
|||
|
||||
## Receiving Events
|
||||
|
||||
Two ways to receive events: SSE streaming (recommended) or polling.
|
||||
Two ways to receive events: streaming (recommended) or polling.
|
||||
|
||||
### Streaming
|
||||
|
||||
Use SSE for real-time events with automatic reconnection support.
|
||||
Use streaming for real-time events with automatic reconnection support.
|
||||
|
||||
```typescript
|
||||
import { SandboxAgentClient } from "sandbox-agent";
|
||||
|
|
@ -44,7 +42,7 @@ for await (const event of client.streamEvents("my-session", { offset })) {
|
|||
|
||||
### Polling
|
||||
|
||||
If you can't use SSE streaming, poll the events endpoint:
|
||||
If you can't use streaming, poll the events endpoint:
|
||||
|
||||
```typescript
|
||||
const lastEvent = await db.getLastEvent("my-session");
|
||||
|
|
@ -244,7 +242,7 @@ const events = await redis.lrange(`session:${sessionId}`, offset, -1);
|
|||
|
||||
## Handling disconnects
|
||||
|
||||
The SSE stream may disconnect due to network issues. Handle reconnection gracefully:
|
||||
The event stream may disconnect due to network issues. Handle reconnection gracefully:
|
||||
|
||||
```typescript
|
||||
async function streamWithRetry(sessionId: string) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"license": {
|
||||
"name": "Apache-2.0"
|
||||
},
|
||||
"version": "0.3.0"
|
||||
"version": "0.3.2"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
|
|
@ -20,9 +20,7 @@
|
|||
"paths": {
|
||||
"/v1/acp": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_acp_servers",
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
@ -40,9 +38,7 @@
|
|||
},
|
||||
"/v1/acp/{server_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_acp",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -92,9 +88,7 @@
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "post_v1_acp",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -204,9 +198,7 @@
|
|||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "delete_v1_acp",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -228,9 +220,7 @@
|
|||
},
|
||||
"/v1/agents": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_agents",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -280,9 +270,7 @@
|
|||
},
|
||||
"/v1/agents/{agent}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_agent",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -351,9 +339,7 @@
|
|||
},
|
||||
"/v1/agents/{agent}/install": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "post_v1_agent_install",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -412,9 +398,7 @@
|
|||
},
|
||||
"/v1/config/mcp": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_config_mcp",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -460,9 +444,7 @@
|
|||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "put_v1_config_mcp",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -501,9 +483,7 @@
|
|||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "delete_v1_config_mcp",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -534,9 +514,7 @@
|
|||
},
|
||||
"/v1/config/skills": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_config_skills",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -582,9 +560,7 @@
|
|||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "put_v1_config_skills",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -623,9 +599,7 @@
|
|||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "delete_v1_config_skills",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -656,9 +630,7 @@
|
|||
},
|
||||
"/v1/fs/entries": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_fs_entries",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -691,9 +663,7 @@
|
|||
},
|
||||
"/v1/fs/entry": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "delete_v1_fs_entry",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -732,9 +702,7 @@
|
|||
},
|
||||
"/v1/fs/file": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_fs_file",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -754,9 +722,7 @@
|
|||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "put_v1_fs_file",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -796,9 +762,7 @@
|
|||
},
|
||||
"/v1/fs/mkdir": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "post_v1_fs_mkdir",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -827,9 +791,7 @@
|
|||
},
|
||||
"/v1/fs/move": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "post_v1_fs_move",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
@ -857,9 +819,7 @@
|
|||
},
|
||||
"/v1/fs/stat": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_fs_stat",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -888,9 +848,7 @@
|
|||
},
|
||||
"/v1/fs/upload-batch": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "post_v1_fs_upload_batch",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -931,9 +889,7 @@
|
|||
},
|
||||
"/v1/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"operationId": "get_v1_health",
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
@ -951,9 +907,7 @@
|
|||
},
|
||||
"/v1/processes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"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",
|
||||
|
|
@ -981,9 +935,7 @@
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Create a long-lived managed process.",
|
||||
"description": "Spawns a new process with the given command and arguments. Supports both\npipe-based and PTY (tty) modes. Returns the process descriptor on success.",
|
||||
"operationId": "post_v1_processes",
|
||||
|
|
@ -1043,9 +995,7 @@
|
|||
},
|
||||
"/v1/processes/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Get process runtime configuration.",
|
||||
"description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.",
|
||||
"operationId": "get_v1_processes_config",
|
||||
|
|
@ -1073,9 +1023,7 @@
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Update process runtime configuration.",
|
||||
"description": "Replaces the runtime configuration for the process management API.\nValidates that all values are non-zero and clamps default timeout to max.",
|
||||
"operationId": "post_v1_processes_config",
|
||||
|
|
@ -1125,9 +1073,7 @@
|
|||
},
|
||||
"/v1/processes/run": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Run a one-shot command.",
|
||||
"description": "Executes a command to completion and returns its stdout, stderr, exit code,\nand duration. Supports configurable timeout and output size limits.",
|
||||
"operationId": "post_v1_processes_run",
|
||||
|
|
@ -1177,9 +1123,7 @@
|
|||
},
|
||||
"/v1/processes/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Get a single process by ID.",
|
||||
"description": "Returns the current state of a managed process including its status,\nPID, exit code, and creation/exit timestamps.",
|
||||
"operationId": "get_v1_process",
|
||||
|
|
@ -1228,9 +1172,7 @@
|
|||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Delete a process record.",
|
||||
"description": "Removes a stopped process from the runtime. Returns 409 if the process\nis still running; stop or kill it first.",
|
||||
"operationId": "delete_v1_process",
|
||||
|
|
@ -1284,9 +1226,7 @@
|
|||
},
|
||||
"/v1/processes/{id}/input": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Write input to a process.",
|
||||
"description": "Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).\nData can be encoded as base64, utf8, or text. Returns 413 if the decoded\npayload exceeds the configured `maxInputBytesPerRequest` limit.",
|
||||
"operationId": "post_v1_process_input",
|
||||
|
|
@ -1367,9 +1307,7 @@
|
|||
},
|
||||
"/v1/processes/{id}/kill": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Send SIGKILL to a process.",
|
||||
"description": "Sends SIGKILL to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.",
|
||||
"operationId": "post_v1_process_kill",
|
||||
|
|
@ -1432,9 +1370,7 @@
|
|||
},
|
||||
"/v1/processes/{id}/logs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Fetch process logs.",
|
||||
"description": "Returns buffered log entries for a process. Supports filtering by stream\ntype, tail count, and sequence-based resumption. When `follow=true`,\nreturns an SSE stream that replays buffered entries then streams live output.",
|
||||
"operationId": "get_v1_process_logs",
|
||||
|
|
@ -1532,9 +1468,7 @@
|
|||
},
|
||||
"/v1/processes/{id}/stop": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Send SIGTERM to a process.",
|
||||
"description": "Sends SIGTERM to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.",
|
||||
"operationId": "post_v1_process_stop",
|
||||
|
|
@ -1597,9 +1531,7 @@
|
|||
},
|
||||
"/v1/processes/{id}/terminal/resize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Resize a process terminal.",
|
||||
"description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.",
|
||||
"operationId": "post_v1_process_terminal_resize",
|
||||
|
|
@ -1680,9 +1612,7 @@
|
|||
},
|
||||
"/v1/processes/{id}/terminal/ws": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"v1"
|
||||
],
|
||||
"tags": ["v1"],
|
||||
"summary": "Open an interactive WebSocket terminal session.",
|
||||
"description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.",
|
||||
"operationId": "get_v1_process_terminal_ws",
|
||||
|
|
@ -1759,9 +1689,7 @@
|
|||
"schemas": {
|
||||
"AcpEnvelope": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"jsonrpc"
|
||||
],
|
||||
"required": ["jsonrpc"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"nullable": true
|
||||
|
|
@ -1795,11 +1723,7 @@
|
|||
},
|
||||
"AcpServerInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"serverId",
|
||||
"agent",
|
||||
"createdAtMs"
|
||||
],
|
||||
"required": ["serverId", "agent", "createdAtMs"],
|
||||
"properties": {
|
||||
"agent": {
|
||||
"type": "string"
|
||||
|
|
@ -1815,9 +1739,7 @@
|
|||
},
|
||||
"AcpServerListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"servers"
|
||||
],
|
||||
"required": ["servers"],
|
||||
"properties": {
|
||||
"servers": {
|
||||
"type": "array",
|
||||
|
|
@ -1908,12 +1830,7 @@
|
|||
},
|
||||
"AgentInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"installed",
|
||||
"credentialsAvailable",
|
||||
"capabilities"
|
||||
],
|
||||
"required": ["id", "installed", "credentialsAvailable", "capabilities"],
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"$ref": "#/components/schemas/AgentCapabilities"
|
||||
|
|
@ -1956,11 +1873,7 @@
|
|||
},
|
||||
"AgentInstallArtifact": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"path",
|
||||
"source"
|
||||
],
|
||||
"required": ["kind", "path", "source"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string"
|
||||
|
|
@ -1996,10 +1909,7 @@
|
|||
},
|
||||
"AgentInstallResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"already_installed",
|
||||
"artifacts"
|
||||
],
|
||||
"required": ["already_installed", "artifacts"],
|
||||
"properties": {
|
||||
"already_installed": {
|
||||
"type": "boolean"
|
||||
|
|
@ -2014,9 +1924,7 @@
|
|||
},
|
||||
"AgentListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agents"
|
||||
],
|
||||
"required": ["agents"],
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
|
|
@ -2049,9 +1957,7 @@
|
|||
},
|
||||
"FsActionResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
|
|
@ -2060,9 +1966,7 @@
|
|||
},
|
||||
"FsDeleteQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
|
|
@ -2084,12 +1988,7 @@
|
|||
},
|
||||
"FsEntry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"entryType",
|
||||
"size"
|
||||
],
|
||||
"required": ["name", "path", "entryType", "size"],
|
||||
"properties": {
|
||||
"entryType": {
|
||||
"$ref": "#/components/schemas/FsEntryType"
|
||||
|
|
@ -2113,17 +2012,11 @@
|
|||
},
|
||||
"FsEntryType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"file",
|
||||
"directory"
|
||||
]
|
||||
"enum": ["file", "directory"]
|
||||
},
|
||||
"FsMoveRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"required": ["from", "to"],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string"
|
||||
|
|
@ -2139,10 +2032,7 @@
|
|||
},
|
||||
"FsMoveResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"required": ["from", "to"],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string"
|
||||
|
|
@ -2154,9 +2044,7 @@
|
|||
},
|
||||
"FsPathQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
|
|
@ -2165,11 +2053,7 @@
|
|||
},
|
||||
"FsStat": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"entryType",
|
||||
"size"
|
||||
],
|
||||
"required": ["path", "entryType", "size"],
|
||||
"properties": {
|
||||
"entryType": {
|
||||
"$ref": "#/components/schemas/FsEntryType"
|
||||
|
|
@ -2199,10 +2083,7 @@
|
|||
},
|
||||
"FsUploadBatchResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"paths",
|
||||
"truncated"
|
||||
],
|
||||
"required": ["paths", "truncated"],
|
||||
"properties": {
|
||||
"paths": {
|
||||
"type": "array",
|
||||
|
|
@ -2217,10 +2098,7 @@
|
|||
},
|
||||
"FsWriteResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"bytesWritten"
|
||||
],
|
||||
"required": ["path", "bytesWritten"],
|
||||
"properties": {
|
||||
"bytesWritten": {
|
||||
"type": "integer",
|
||||
|
|
@ -2234,9 +2112,7 @@
|
|||
},
|
||||
"HealthResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
|
|
@ -2245,10 +2121,7 @@
|
|||
},
|
||||
"McpConfigQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"directory",
|
||||
"mcpName"
|
||||
],
|
||||
"required": ["directory", "mcpName"],
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
|
|
@ -2262,10 +2135,7 @@
|
|||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command",
|
||||
"type"
|
||||
],
|
||||
"required": ["command", "type"],
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
|
|
@ -2299,18 +2169,13 @@
|
|||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local"
|
||||
]
|
||||
"enum": ["local"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url",
|
||||
"type"
|
||||
],
|
||||
"required": ["url", "type"],
|
||||
"properties": {
|
||||
"bearerTokenEnvVar": {
|
||||
"type": "string",
|
||||
|
|
@ -2358,9 +2223,7 @@
|
|||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"remote"
|
||||
]
|
||||
"enum": ["remote"]
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
|
|
@ -2374,11 +2237,7 @@
|
|||
},
|
||||
"ProblemDetails": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"title",
|
||||
"status"
|
||||
],
|
||||
"required": ["type", "title", "status"],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string",
|
||||
|
|
@ -2404,14 +2263,7 @@
|
|||
},
|
||||
"ProcessConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"maxConcurrentProcesses",
|
||||
"defaultRunTimeoutMs",
|
||||
"maxRunTimeoutMs",
|
||||
"maxOutputBytes",
|
||||
"maxLogBytesPerProcess",
|
||||
"maxInputBytesPerRequest"
|
||||
],
|
||||
"required": ["maxConcurrentProcesses", "defaultRunTimeoutMs", "maxRunTimeoutMs", "maxOutputBytes", "maxLogBytesPerProcess", "maxInputBytesPerRequest"],
|
||||
"properties": {
|
||||
"defaultRunTimeoutMs": {
|
||||
"type": "integer",
|
||||
|
|
@ -2443,9 +2295,7 @@
|
|||
},
|
||||
"ProcessCreateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"required": ["command"],
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
|
|
@ -2476,15 +2326,7 @@
|
|||
},
|
||||
"ProcessInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"command",
|
||||
"args",
|
||||
"tty",
|
||||
"interactive",
|
||||
"status",
|
||||
"createdAtMs"
|
||||
],
|
||||
"required": ["id", "command", "args", "tty", "interactive", "status", "createdAtMs"],
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
|
|
@ -2535,9 +2377,7 @@
|
|||
},
|
||||
"ProcessInputRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"required": ["data"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
|
|
@ -2550,9 +2390,7 @@
|
|||
},
|
||||
"ProcessInputResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bytesWritten"
|
||||
],
|
||||
"required": ["bytesWritten"],
|
||||
"properties": {
|
||||
"bytesWritten": {
|
||||
"type": "integer",
|
||||
|
|
@ -2562,9 +2400,7 @@
|
|||
},
|
||||
"ProcessListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"processes"
|
||||
],
|
||||
"required": ["processes"],
|
||||
"properties": {
|
||||
"processes": {
|
||||
"type": "array",
|
||||
|
|
@ -2576,13 +2412,7 @@
|
|||
},
|
||||
"ProcessLogEntry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sequence",
|
||||
"stream",
|
||||
"timestampMs",
|
||||
"data",
|
||||
"encoding"
|
||||
],
|
||||
"required": ["sequence", "stream", "timestampMs", "data", "encoding"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
|
|
@ -2634,11 +2464,7 @@
|
|||
},
|
||||
"ProcessLogsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"processId",
|
||||
"stream",
|
||||
"entries"
|
||||
],
|
||||
"required": ["processId", "stream", "entries"],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
|
|
@ -2656,18 +2482,11 @@
|
|||
},
|
||||
"ProcessLogsStream": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdout",
|
||||
"stderr",
|
||||
"combined",
|
||||
"pty"
|
||||
]
|
||||
"enum": ["stdout", "stderr", "combined", "pty"]
|
||||
},
|
||||
"ProcessRunRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"required": ["command"],
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "array",
|
||||
|
|
@ -2703,14 +2522,7 @@
|
|||
},
|
||||
"ProcessRunResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"timedOut",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"stdoutTruncated",
|
||||
"stderrTruncated",
|
||||
"durationMs"
|
||||
],
|
||||
"required": ["timedOut", "stdout", "stderr", "stdoutTruncated", "stderrTruncated", "durationMs"],
|
||||
"properties": {
|
||||
"durationMs": {
|
||||
"type": "integer",
|
||||
|
|
@ -2752,17 +2564,11 @@
|
|||
},
|
||||
"ProcessState": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"running",
|
||||
"exited"
|
||||
]
|
||||
"enum": ["running", "exited"]
|
||||
},
|
||||
"ProcessTerminalResizeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cols",
|
||||
"rows"
|
||||
],
|
||||
"required": ["cols", "rows"],
|
||||
"properties": {
|
||||
"cols": {
|
||||
"type": "integer",
|
||||
|
|
@ -2778,10 +2584,7 @@
|
|||
},
|
||||
"ProcessTerminalResizeResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cols",
|
||||
"rows"
|
||||
],
|
||||
"required": ["cols", "rows"],
|
||||
"properties": {
|
||||
"cols": {
|
||||
"type": "integer",
|
||||
|
|
@ -2797,16 +2600,11 @@
|
|||
},
|
||||
"ServerStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"running",
|
||||
"stopped"
|
||||
]
|
||||
"enum": ["running", "stopped"]
|
||||
},
|
||||
"ServerStatusInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/ServerStatus"
|
||||
|
|
@ -2821,10 +2619,7 @@
|
|||
},
|
||||
"SkillSource": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"source"
|
||||
],
|
||||
"required": ["type", "source"],
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
|
|
@ -2851,9 +2646,7 @@
|
|||
},
|
||||
"SkillsConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sources"
|
||||
],
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
|
|
@ -2865,10 +2658,7 @@
|
|||
},
|
||||
"SkillsConfigQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"directory",
|
||||
"skillName"
|
||||
],
|
||||
"required": ["directory", "skillName"],
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
|
|
@ -2886,4 +2676,4 @@
|
|||
"description": "ACP proxy v1 API"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ The process API supports:
|
|||
|
||||
- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code
|
||||
- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes
|
||||
- **Log streaming** — fetch buffered logs or follow live output via SSE
|
||||
- **Log streaming** — fetch buffered logs or follow live output
|
||||
- **Terminals** — full PTY support with bidirectional WebSocket I/O
|
||||
- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined"
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Follow logs via SSE
|
||||
### Follow logs
|
||||
|
||||
Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives.
|
||||
|
||||
|
|
|
|||
|
|
@ -61,9 +61,11 @@ icon: "rocket"
|
|||
|
||||
<Tab title="Docker">
|
||||
```bash
|
||||
docker run -e ANTHROPIC_API_KEY="sk-ant-..." \
|
||||
docker run -p 2468:2468 \
|
||||
-e ANTHROPIC_API_KEY="sk-ant-..." \
|
||||
-e OPENAI_API_KEY="sk-..." \
|
||||
your-image
|
||||
rivetdev/sandbox-agent:0.3.1-full \
|
||||
server --no-token --host 0.0.0.0 --port 2468
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
|
@ -75,6 +77,9 @@ icon: "rocket"
|
|||
<Accordion title="Testing without API keys">
|
||||
Use the `mock` agent for SDK and integration testing without provider credentials.
|
||||
</Accordion>
|
||||
<Accordion title="Multi-tenant and per-user billing">
|
||||
For per-tenant token tracking, budget enforcement, or usage-based billing, see [LLM Credentials](/llm-credentials) for gateway options like OpenRouter, LiteLLM, and Portkey.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
|
|
@ -217,12 +222,7 @@ icon: "rocket"
|
|||
To preinstall agents:
|
||||
|
||||
```bash
|
||||
sandbox-agent install-agent claude
|
||||
sandbox-agent install-agent codex
|
||||
sandbox-agent install-agent opencode
|
||||
sandbox-agent install-agent amp
|
||||
sandbox-agent install-agent pi
|
||||
sandbox-agent install-agent cursor
|
||||
sandbox-agent install-agent --all
|
||||
```
|
||||
|
||||
If agents are not installed up front, they are lazily installed when creating a session.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ icon: "react"
|
|||
|
||||
`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK.
|
||||
|
||||
Current exports:
|
||||
|
||||
- `AgentConversation` for a combined transcript + composer surface
|
||||
- `ProcessTerminal` for attaching to a running tty process
|
||||
- `AgentTranscript` for rendering session/message timelines without bundling any styles
|
||||
- `ChatComposer` for a reusable prompt input/send surface
|
||||
- `useTranscriptVirtualizer` for wiring large transcript lists to a scroll container
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
|
|
@ -101,3 +109,137 @@ export default function TerminalPane() {
|
|||
- `onExit`, `onError`: optional lifecycle callbacks
|
||||
|
||||
See [Processes](/processes) for the lower-level terminal APIs.
|
||||
|
||||
## Headless transcript
|
||||
|
||||
`AgentTranscript` is intentionally unstyled. It follows the common headless React pattern used by libraries like Radix, Headless UI, and React Aria: behavior lives in the component, while styling stays in your app through `className`, slot-level `classNames`, and `data-*` state attributes on the rendered DOM.
|
||||
|
||||
```tsx TranscriptPane.tsx
|
||||
import {
|
||||
AgentTranscript,
|
||||
type AgentTranscriptClassNames,
|
||||
type TranscriptEntry,
|
||||
} from "@sandbox-agent/react";
|
||||
|
||||
const transcriptClasses: Partial<AgentTranscriptClassNames> = {
|
||||
root: "transcript",
|
||||
message: "transcript-message",
|
||||
messageContent: "transcript-message-content",
|
||||
toolGroupContainer: "transcript-tools",
|
||||
toolGroupHeader: "transcript-tools-header",
|
||||
toolItem: "transcript-tool-item",
|
||||
toolItemHeader: "transcript-tool-item-header",
|
||||
toolItemBody: "transcript-tool-item-body",
|
||||
divider: "transcript-divider",
|
||||
dividerText: "transcript-divider-text",
|
||||
error: "transcript-error",
|
||||
};
|
||||
|
||||
export function TranscriptPane({ entries }: { entries: TranscriptEntry[] }) {
|
||||
return (
|
||||
<AgentTranscript
|
||||
entries={entries}
|
||||
classNames={transcriptClasses}
|
||||
renderMessageText={(entry) => <div>{entry.text}</div>}
|
||||
renderInlinePendingIndicator={() => <span>...</span>}
|
||||
renderToolGroupIcon={() => <span>Events</span>}
|
||||
renderChevron={(expanded) => <span>{expanded ? "Hide" : "Show"}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
.transcript {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.transcript [data-slot="message"][data-variant="user"] .transcript-message-content {
|
||||
background: #161616;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.transcript [data-slot="message"][data-variant="assistant"] .transcript-message-content {
|
||||
background: #f4f4f0;
|
||||
color: #161616;
|
||||
}
|
||||
|
||||
.transcript [data-slot="tool-item"][data-failed="true"] {
|
||||
border-color: #d33;
|
||||
}
|
||||
|
||||
.transcript [data-slot="tool-item-header"][data-expanded="true"] {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
`AgentTranscript` accepts `TranscriptEntry[]`, which matches the Inspector timeline shape:
|
||||
|
||||
- `message` entries render user/assistant text
|
||||
- `tool` entries render expandable tool input/output sections
|
||||
- `reasoning` entries render expandable reasoning blocks
|
||||
- `meta` entries render status rows or expandable metadata details
|
||||
|
||||
Useful props:
|
||||
|
||||
- `className`: root class hook
|
||||
- `classNames`: slot-level class hooks for styling from outside the package
|
||||
- `scrollRef` + `virtualize`: opt into TanStack Virtual against an external scroll container
|
||||
- `renderMessageText`: custom text or markdown renderer
|
||||
- `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides
|
||||
- `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides
|
||||
- `isDividerEntry`, `canOpenEvent`, `getToolGroupSummary`: behavior overrides for grouping and labels
|
||||
|
||||
## Transcript virtualization hook
|
||||
|
||||
`useTranscriptVirtualizer` exposes the same TanStack Virtual behavior used by `AgentTranscript` when `virtualize` is enabled.
|
||||
|
||||
- Pass the grouped transcript rows you want to virtualize
|
||||
- Pass a `scrollRef` that points at the actual scrollable element
|
||||
- Use it when you need transcript-aware virtualization outside the stock `AgentTranscript` renderer
|
||||
|
||||
## Composer and conversation
|
||||
|
||||
`ChatComposer` is the headless message input. `AgentConversation` composes `AgentTranscript` and `ChatComposer` so apps can reuse the transcript/composer pairing without pulling in Inspector session chrome.
|
||||
|
||||
```tsx ConversationPane.tsx
|
||||
import { AgentConversation, type TranscriptEntry } from "@sandbox-agent/react";
|
||||
|
||||
export function ConversationPane({
|
||||
entries,
|
||||
message,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
entries: TranscriptEntry[];
|
||||
message: string;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AgentConversation
|
||||
entries={entries}
|
||||
emptyState={<div>Start the conversation.</div>}
|
||||
transcriptProps={{
|
||||
renderMessageText: (entry) => <div>{entry.text}</div>,
|
||||
}}
|
||||
composerProps={{
|
||||
message,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
placeholder: "Send a message...",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Useful `ChatComposer` props:
|
||||
|
||||
- `className` and `classNames` for external styling
|
||||
- `inputRef` to manage focus or autoresize from the consumer
|
||||
- `textareaProps` for lower-level textarea behavior
|
||||
- `allowEmptySubmit` when the submit action is valid without draft text, such as a stop button
|
||||
|
||||
Use `transcriptProps` and `composerProps` when you want the shared composition but still need custom rendering or behavior. Use `transcriptClassNames` and `composerClassNames` when you want styling hooks for each subcomponent.
|
||||
|
|
|
|||
|
|
@ -138,6 +138,19 @@ const options = await session.getConfigOptions();
|
|||
const modes = await session.getModes();
|
||||
```
|
||||
|
||||
Handle permission requests from agents that ask before executing tools:
|
||||
|
||||
```ts
|
||||
const claude = await sdk.createSession({
|
||||
agent: "claude",
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
claude.onPermissionRequest((request) => {
|
||||
void claude.respondPermission(request.id, "once");
|
||||
});
|
||||
```
|
||||
|
||||
See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
|
||||
|
||||
## Events
|
||||
|
|
@ -209,6 +222,10 @@ Parameters:
|
|||
- `baseUrl` (required unless `fetch` is provided): Sandbox Agent server URL
|
||||
- `token` (optional): Bearer token for authenticated servers
|
||||
- `headers` (optional): Additional request headers
|
||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
||||
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls
|
||||
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
|
||||
|
||||
## LLM credentials
|
||||
|
||||
Sandbox Agent supports personal API keys, shared organization keys, and per-tenant gateway keys with budget enforcement. See [LLM Credentials](/llm-credentials) for setup details.
|
||||
|
|
|
|||
|
|
@ -11,17 +11,14 @@ setupImage();
|
|||
|
||||
console.log("Creating BoxLite sandbox...");
|
||||
const box = new SimpleBox({
|
||||
rootfsPath: OCI_DIR,
|
||||
env,
|
||||
ports: [{ hostPort: 3000, guestPort: 3000 }],
|
||||
diskSizeGb: 4,
|
||||
rootfsPath: OCI_DIR,
|
||||
env,
|
||||
ports: [{ hostPort: 3000, guestPort: 3000 }],
|
||||
diskSizeGb: 4,
|
||||
});
|
||||
|
||||
console.log("Starting server...");
|
||||
const result = await box.exec(
|
||||
"sh", "-c",
|
||||
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &",
|
||||
);
|
||||
const result = await box.exec("sh", "-c", "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
|
||||
if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.stderr}`);
|
||||
|
||||
const baseUrl = "http://localhost:3000";
|
||||
|
|
@ -36,9 +33,9 @@ console.log(" Press Ctrl+C to stop.");
|
|||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
const cleanup = async () => {
|
||||
clearInterval(keepAlive);
|
||||
await box.stop();
|
||||
process.exit(0);
|
||||
clearInterval(keepAlive);
|
||||
await box.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ export const DOCKER_IMAGE = "sandbox-agent-boxlite";
|
|||
export const OCI_DIR = new URL("../oci-image", import.meta.url).pathname;
|
||||
|
||||
export function setupImage() {
|
||||
console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`);
|
||||
execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" });
|
||||
console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`);
|
||||
execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" });
|
||||
|
||||
if (!existsSync(`${OCI_DIR}/oci-layout`)) {
|
||||
console.log("Exporting to OCI layout...");
|
||||
mkdirSync(OCI_DIR, { recursive: true });
|
||||
execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" });
|
||||
}
|
||||
if (!existsSync(`${OCI_DIR}/oci-layout`)) {
|
||||
console.log("Exporting to OCI layout...");
|
||||
mkdirSync(OCI_DIR, { recursive: true });
|
||||
execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function App() {
|
|||
if (event.type === "permission.requested") {
|
||||
const data = event.data as PermissionEventData;
|
||||
log(`[Auto-approved] ${data.action}`);
|
||||
await client.replyPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
|
||||
await client.respondPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
|
||||
}
|
||||
|
||||
// Reject questions (don't support interactive input)
|
||||
|
|
@ -128,7 +128,7 @@ export function App() {
|
|||
console.error("Event stream error:", err);
|
||||
}
|
||||
},
|
||||
[log]
|
||||
[log],
|
||||
);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
|
|
@ -162,12 +162,7 @@ export function App() {
|
|||
<div style={styles.connectForm}>
|
||||
<label style={styles.label}>
|
||||
Sandbox name:
|
||||
<input
|
||||
style={styles.input}
|
||||
value={sandboxName}
|
||||
onChange={(e) => setSandboxName(e.target.value)}
|
||||
placeholder="demo"
|
||||
/>
|
||||
<input style={styles.input} value={sandboxName} onChange={(e) => setSandboxName(e.target.value)} placeholder="demo" />
|
||||
</label>
|
||||
<button style={styles.button} onClick={connect}>
|
||||
Connect
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ import { App } from "./App";
|
|||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,65 +2,61 @@ import type { Sandbox } from "@cloudflare/sandbox";
|
|||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
export type PromptRequest = {
|
||||
agent?: string;
|
||||
prompt?: string;
|
||||
agent?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
export async function runPromptEndpointStream(
|
||||
sandbox: Sandbox,
|
||||
request: PromptRequest,
|
||||
port: number,
|
||||
emit: (event: { type: string; [key: string]: unknown }) => Promise<void> | void,
|
||||
sandbox: Sandbox,
|
||||
request: PromptRequest,
|
||||
port: number,
|
||||
emit: (event: { type: string; [key: string]: unknown }) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const client = await SandboxAgent.connect({
|
||||
fetch: (req, init) =>
|
||||
sandbox.containerFetch(
|
||||
req,
|
||||
{
|
||||
...(init ?? {}),
|
||||
// Cloudflare containerFetch may drop long-lived update streams when
|
||||
// a forwarded AbortSignal is cancelled; clear it for this path.
|
||||
signal: undefined,
|
||||
},
|
||||
port,
|
||||
),
|
||||
});
|
||||
const client = await SandboxAgent.connect({
|
||||
fetch: (req, init) =>
|
||||
sandbox.containerFetch(
|
||||
req,
|
||||
{
|
||||
...(init ?? {}),
|
||||
// Cloudflare containerFetch may drop long-lived update streams when
|
||||
// a forwarded AbortSignal is cancelled; clear it for this path.
|
||||
signal: undefined,
|
||||
},
|
||||
port,
|
||||
),
|
||||
});
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
agent: request.agent ?? "codex",
|
||||
});
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
agent: request.agent ?? "codex",
|
||||
});
|
||||
|
||||
const promptText =
|
||||
request.prompt?.trim() || "Reply with a short confirmation.";
|
||||
await emit({
|
||||
type: "session.created",
|
||||
sessionId: session.id,
|
||||
agent: session.agent,
|
||||
prompt: promptText,
|
||||
});
|
||||
const promptText = request.prompt?.trim() || "Reply with a short confirmation.";
|
||||
await emit({
|
||||
type: "session.created",
|
||||
sessionId: session.id,
|
||||
agent: session.agent,
|
||||
prompt: promptText,
|
||||
});
|
||||
|
||||
let pendingWrites: Promise<void> = Promise.resolve();
|
||||
unsubscribe = session.onEvent((event) => {
|
||||
pendingWrites = pendingWrites
|
||||
.then(async () => {
|
||||
await emit({ type: "session.event", event });
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
let pendingWrites: Promise<void> = Promise.resolve();
|
||||
unsubscribe = session.onEvent((event) => {
|
||||
pendingWrites = pendingWrites
|
||||
.then(async () => {
|
||||
await emit({ type: "session.event", event });
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const response = await session.prompt([{ type: "text", text: promptText }]);
|
||||
await pendingWrites;
|
||||
await emit({ type: "prompt.response", response });
|
||||
await emit({ type: "prompt.completed" });
|
||||
} finally {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
await Promise.race([
|
||||
client.dispose(),
|
||||
new Promise((resolve) => setTimeout(resolve, 250)),
|
||||
]);
|
||||
}
|
||||
const response = await session.prompt([{ type: "text", text: promptText }]);
|
||||
await pendingWrites;
|
||||
await emit({ type: "prompt.response", response });
|
||||
await emit({ type: "prompt.completed" });
|
||||
} finally {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
await Promise.race([client.dispose(), new Promise((resolve) => setTimeout(resolve, 250))]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { resolve } from "node:path";
|
||||
|
||||
const PORT = 3000;
|
||||
const REQUEST_TIMEOUT_MS =
|
||||
Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000;
|
||||
const REQUEST_TIMEOUT_MS = Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000;
|
||||
|
||||
/**
|
||||
* Detects and validates the provider to use.
|
||||
|
|
@ -24,28 +23,22 @@ const REQUEST_TIMEOUT_MS =
|
|||
*/
|
||||
function resolveProvider(): ProviderName {
|
||||
const providerOverride = process.env.COMPUTESDK_PROVIDER;
|
||||
|
||||
|
||||
if (providerOverride) {
|
||||
if (!isValidProvider(providerOverride)) {
|
||||
throw new Error(
|
||||
`Unsupported ComputeSDK provider "${providerOverride}". Supported providers: ${PROVIDER_NAMES.join(", ")}`
|
||||
);
|
||||
throw new Error(`Unsupported ComputeSDK provider "${providerOverride}". Supported providers: ${PROVIDER_NAMES.join(", ")}`);
|
||||
}
|
||||
if (!isProviderAuthComplete(providerOverride)) {
|
||||
const missing = getMissingEnvVars(providerOverride);
|
||||
throw new Error(
|
||||
`Missing credentials for provider "${providerOverride}". Set: ${missing.join(", ")}`
|
||||
);
|
||||
throw new Error(`Missing credentials for provider "${providerOverride}". Set: ${missing.join(", ")}`);
|
||||
}
|
||||
console.log(`Using ComputeSDK provider: ${providerOverride} (explicit)`);
|
||||
return providerOverride as ProviderName;
|
||||
}
|
||||
|
||||
|
||||
const detected = detectProvider();
|
||||
if (!detected) {
|
||||
throw new Error(
|
||||
`No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}`
|
||||
);
|
||||
throw new Error(`No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}`);
|
||||
}
|
||||
console.log(`Using ComputeSDK provider: ${detected} (auto-detected)`);
|
||||
return detected as ProviderName;
|
||||
|
|
@ -53,20 +46,19 @@ function resolveProvider(): ProviderName {
|
|||
|
||||
function configureComputeSDK(): void {
|
||||
const provider = resolveProvider();
|
||||
|
||||
|
||||
const config: ExplicitComputeConfig = {
|
||||
provider,
|
||||
computesdkApiKey: process.env.COMPUTESDK_API_KEY,
|
||||
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
|
||||
const providerConfig = getProviderConfigFromEnv(provider);
|
||||
if (Object.keys(providerConfig).length > 0) {
|
||||
const configWithProvider =
|
||||
config as ExplicitComputeConfig & Record<ProviderName, Record<string, string>>;
|
||||
const configWithProvider = config as ExplicitComputeConfig & Record<ProviderName, Record<string, string>>;
|
||||
configWithProvider[provider] = providerConfig;
|
||||
}
|
||||
|
||||
|
||||
compute.setConfig(config);
|
||||
}
|
||||
|
||||
|
|
@ -149,9 +141,7 @@ export async function runComputeSdkExample(): Promise<void> {
|
|||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
const isDirectRun = Boolean(
|
||||
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
);
|
||||
const isDirectRun = Boolean(process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url));
|
||||
|
||||
if (isDirectRun) {
|
||||
runComputeSdkExample().catch((error) => {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ import { setupComputeSdkSandboxAgent } from "../src/computesdk.ts";
|
|||
const hasModal = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET);
|
||||
const hasVercel = Boolean(process.env.VERCEL_TOKEN || process.env.VERCEL_OIDC_TOKEN);
|
||||
const hasProviderKey = Boolean(
|
||||
process.env.BLAXEL_API_KEY ||
|
||||
process.env.CSB_API_KEY ||
|
||||
process.env.DAYTONA_API_KEY ||
|
||||
process.env.E2B_API_KEY ||
|
||||
hasModal ||
|
||||
hasVercel
|
||||
process.env.BLAXEL_API_KEY || process.env.CSB_API_KEY || process.env.DAYTONA_API_KEY || process.env.E2B_API_KEY || hasModal || hasVercel,
|
||||
);
|
||||
|
||||
const shouldRun = Boolean(process.env.COMPUTESDK_API_KEY) && hasProviderKey;
|
||||
|
|
@ -34,6 +29,6 @@ describe("computesdk example", () => {
|
|||
await cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"start:snapshot": "tsx src/daytona-with-snapshot.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import { Daytona, Image } from "@daytonaio/sdk";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
|
||||
const daytona = new Daytona();
|
||||
|
||||
const envVars: Record<string, string> = {};
|
||||
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;
|
||||
|
||||
// Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs)
|
||||
const 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",
|
||||
);
|
||||
|
||||
console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)...");
|
||||
const sandbox = await daytona.create({ envVars, image, autoStopInterval: 0 }, { timeout: 180 });
|
||||
|
||||
await sandbox.process.executeCommand(
|
||||
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &",
|
||||
);
|
||||
|
||||
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
||||
|
||||
console.log("Connecting to server...");
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
const cleanup = async () => {
|
||||
clearInterval(keepAlive);
|
||||
await sandbox.delete(60);
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
|
|
@ -5,10 +5,8 @@ import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
|||
const daytona = new Daytona();
|
||||
|
||||
const envVars: Record<string, string> = {};
|
||||
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;
|
||||
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;
|
||||
|
||||
// Use default image and install sandbox-agent at runtime (faster startup, no snapshot build)
|
||||
console.log("Creating Daytona sandbox...");
|
||||
|
|
@ -16,17 +14,13 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 });
|
|||
|
||||
// Install sandbox-agent and start server
|
||||
console.log("Installing sandbox-agent...");
|
||||
await sandbox.process.executeCommand(
|
||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh",
|
||||
);
|
||||
await sandbox.process.executeCommand("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh");
|
||||
|
||||
console.log("Installing agents...");
|
||||
await sandbox.process.executeCommand("sandbox-agent install-agent claude");
|
||||
await sandbox.process.executeCommand("sandbox-agent install-agent codex");
|
||||
|
||||
await sandbox.process.executeCommand(
|
||||
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &",
|
||||
);
|
||||
await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
|
||||
|
||||
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
||||
|
||||
|
|
@ -40,9 +34,9 @@ console.log(" Press Ctrl+C to stop.");
|
|||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
const cleanup = async () => {
|
||||
clearInterval(keepAlive);
|
||||
await sandbox.delete(60);
|
||||
process.exit(0);
|
||||
clearInterval(keepAlive);
|
||||
await sandbox.delete(60);
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@ describe("daytona example", () => {
|
|||
await cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||
import { FULL_IMAGE } from "@sandbox-agent/example-shared/docker";
|
||||
|
||||
const IMAGE = "node:22-bookworm-slim";
|
||||
const IMAGE = FULL_IMAGE;
|
||||
const PORT = 3000;
|
||||
const agent = detectAgent();
|
||||
const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null;
|
||||
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath)
|
||||
? [`${codexAuthPath}:/root/.codex/auth.json:ro`]
|
||||
: [];
|
||||
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/home/sandbox/.codex/auth.json:ro`] : [];
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
|
|
@ -22,7 +21,7 @@ try {
|
|||
await new Promise<void>((resolve, reject) => {
|
||||
docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||
if (err) return reject(err);
|
||||
docker.modem.followProgress(stream, (err: Error | null) => err ? reject(err) : resolve());
|
||||
docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -30,13 +29,7 @@ try {
|
|||
console.log("Starting container...");
|
||||
const container = await docker.createContainer({
|
||||
Image: IMAGE,
|
||||
Cmd: ["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.3.x/install.sh | sh",
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||
].join(" && ")],
|
||||
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`],
|
||||
Env: [
|
||||
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
|
||||
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
|
||||
|
|
@ -54,7 +47,7 @@ await container.start();
|
|||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
|
|
@ -63,8 +56,12 @@ console.log(" Press Ctrl+C to stop.");
|
|||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
const cleanup = async () => {
|
||||
clearInterval(keepAlive);
|
||||
try { await container.stop({ t: 5 }); } catch {}
|
||||
try { await container.remove({ force: true }); } catch {}
|
||||
try {
|
||||
await container.stop({ t: 5 });
|
||||
} catch {}
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@ describe("docker example", () => {
|
|||
await cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@ describe("e2b example", () => {
|
|||
await cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ console.log("Uploading files via batch tar...");
|
|||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
|
||||
const tarPath = path.join(tmpDir, "upload.tar");
|
||||
await tar.create(
|
||||
{ file: tarPath, cwd: tmpDir },
|
||||
["my-project"],
|
||||
);
|
||||
await tar.create({ file: tarPath, cwd: tmpDir }, ["my-project"]);
|
||||
const tarBuffer = await fs.promises.readFile(tarPath);
|
||||
const uploadResult = await client.uploadFsBatch(tarBuffer, { path: "/opt" });
|
||||
console.log(` Uploaded ${uploadResult.paths.length} files: ${uploadResult.paths.join(", ")}`);
|
||||
|
|
@ -54,4 +51,7 @@ console.log(' Try: "read the README in /opt/my-project"');
|
|||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(keepAlive);
|
||||
cleanup().then(() => process.exit(0));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,10 +23,7 @@ console.log("Uploading MCP server bundle...");
|
|||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
|
||||
const bundle = await fs.promises.readFile(serverFile);
|
||||
const written = await client.writeFsFile(
|
||||
{ path: "/opt/mcp/custom-tools/mcp-server.cjs" },
|
||||
bundle,
|
||||
);
|
||||
const written = await client.writeFsFile({ path: "/opt/mcp/custom-tools/mcp-server.cjs" }, bundle);
|
||||
console.log(` Written: ${written.path} (${written.bytesWritten} bytes)`);
|
||||
|
||||
// Create a session with the uploaded MCP server as a local command.
|
||||
|
|
@ -35,12 +32,14 @@ const session = await client.createSession({
|
|||
agent: detectAgent(),
|
||||
sessionInit: {
|
||||
cwd: "/root",
|
||||
mcpServers: [{
|
||||
name: "customTools",
|
||||
command: "node",
|
||||
args: ["/opt/mcp/custom-tools/mcp-server.cjs"],
|
||||
env: [],
|
||||
}],
|
||||
mcpServers: [
|
||||
{
|
||||
name: "customTools",
|
||||
command: "node",
|
||||
args: ["/opt/mcp/custom-tools/mcp-server.cjs"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sessionId = session.id;
|
||||
|
|
@ -49,4 +48,7 @@ console.log(' Try: "generate a random number between 1 and 100"');
|
|||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(keepAlive);
|
||||
cleanup().then(() => process.exit(0));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
|
|||
console.log("Starting sandbox...");
|
||||
const { baseUrl, cleanup } = await startDockerSandbox({
|
||||
port: 3002,
|
||||
setupCommands: [
|
||||
"npm install -g --silent @modelcontextprotocol/server-everything@2026.1.26",
|
||||
],
|
||||
setupCommands: ["npm install -g --silent @modelcontextprotocol/server-everything@2026.1.26"],
|
||||
});
|
||||
|
||||
console.log("Creating session with everything MCP server...");
|
||||
|
|
@ -16,12 +14,14 @@ const session = await client.createSession({
|
|||
agent: detectAgent(),
|
||||
sessionInit: {
|
||||
cwd: "/root",
|
||||
mcpServers: [{
|
||||
name: "everything",
|
||||
command: "mcp-server-everything",
|
||||
args: [],
|
||||
env: [],
|
||||
}],
|
||||
mcpServers: [
|
||||
{
|
||||
name: "everything",
|
||||
command: "mcp-server-everything",
|
||||
args: [],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sessionId = session.id;
|
||||
|
|
@ -30,4 +30,7 @@ console.log(' Try: "generate a random number between 1 and 100"');
|
|||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(keepAlive);
|
||||
cleanup().then(() => process.exit(0));
|
||||
});
|
||||
|
|
|
|||
20
examples/modal/package.json
Normal file
20
examples/modal/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@sandbox-agent/example-modal",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/modal.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"modal": "latest",
|
||||
"@sandbox-agent/example-shared": "workspace:*",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
123
examples/modal/src/modal.ts
Normal file
123
examples/modal/src/modal.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { ModalClient } from "modal";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { run } from "node:test";
|
||||
|
||||
const PORT = 3000;
|
||||
const APP_NAME = "sandbox-agent";
|
||||
|
||||
async function buildSecrets(modal: ModalClient) {
|
||||
const envVars: Record<string, string> = {};
|
||||
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;
|
||||
|
||||
if (Object.keys(envVars).length === 0) return [];
|
||||
return [await modal.secrets.fromObject(envVars)];
|
||||
}
|
||||
|
||||
export async function setupModalSandboxAgent(): Promise<{
|
||||
baseUrl: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const modal = new ModalClient();
|
||||
const app = await modal.apps.fromName(APP_NAME, { createIfMissing: true });
|
||||
|
||||
const image = modal.images
|
||||
.fromRegistry("ubuntu:22.04")
|
||||
.dockerfileCommands([
|
||||
"RUN apt-get update && apt-get install -y curl ca-certificates",
|
||||
"RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||
]);
|
||||
|
||||
const secrets = await buildSecrets(modal);
|
||||
|
||||
console.log("Creating Modal sandbox!");
|
||||
const sb = await modal.sandboxes.create(app, image, {
|
||||
secrets: secrets,
|
||||
encryptedPorts: [PORT],
|
||||
});
|
||||
console.log(`Sandbox created: ${sb.sandboxId}`);
|
||||
|
||||
const exec = async (cmd: string) => {
|
||||
const p = await sb.exec(["bash", "-c", cmd], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await p.wait();
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await p.stderr.readText();
|
||||
throw new Error(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.ANTHROPIC_API_KEY) {
|
||||
console.log("Installing Claude agent...");
|
||||
await exec("sandbox-agent install-agent claude");
|
||||
}
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
console.log("Installing Codex agent...");
|
||||
await exec("sandbox-agent install-agent codex");
|
||||
}
|
||||
|
||||
console.log("Starting server...");
|
||||
|
||||
await sb.exec(
|
||||
["bash", "-c", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT} &`],
|
||||
);
|
||||
|
||||
const tunnels = await sb.tunnels();
|
||||
const tunnel = tunnels[PORT];
|
||||
if (!tunnel) {
|
||||
throw new Error(`No tunnel found for port ${PORT}`);
|
||||
}
|
||||
const baseUrl = tunnel.url;
|
||||
|
||||
console.log("Waiting for server...");
|
||||
await waitForHealth({ baseUrl });
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await sb.terminate();
|
||||
} catch (error) {
|
||||
console.warn("Cleanup failed:", error instanceof Error ? error.message : error);
|
||||
}
|
||||
};
|
||||
|
||||
return { baseUrl, cleanup };
|
||||
}
|
||||
|
||||
export async function runModalExample(): Promise<void> {
|
||||
const { baseUrl, cleanup } = await setupModalSandboxAgent();
|
||||
|
||||
const handleExit = async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.once("SIGINT", handleExit);
|
||||
process.once("SIGTERM", handleExit);
|
||||
|
||||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||
const sessionId = session.id;
|
||||
|
||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
const isDirectRun = Boolean(
|
||||
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
|
||||
if (isDirectRun) {
|
||||
runModalExample().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
28
examples/modal/tests/modal.test.ts
Normal file
28
examples/modal/tests/modal.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildHeaders } from "@sandbox-agent/example-shared";
|
||||
import { setupModalSandboxAgent } from "../src/modal.ts";
|
||||
|
||||
const shouldRun = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET);
|
||||
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;
|
||||
|
||||
const testFn = shouldRun ? it : it.skip;
|
||||
|
||||
describe("modal example", () => {
|
||||
testFn(
|
||||
"starts sandbox-agent and responds to /v1/health",
|
||||
async () => {
|
||||
const { baseUrl, cleanup } = await setupModalSandboxAgent();
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/health`, {
|
||||
headers: buildHeaders({}),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.status).toBe("ok");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
16
examples/modal/tsconfig.json
Normal file
16
examples/modal/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
18
examples/permissions/package.json
Normal file
18
examples/permissions/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@sandbox-agent/example-permissions",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
178
examples/permissions/src/index.ts
Normal file
178
examples/permissions/src/index.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { Command } from "commander";
|
||||
import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent";
|
||||
|
||||
const options = parseOptions();
|
||||
const agent = options.agent.trim().toLowerCase();
|
||||
const autoReply = parsePermissionReply(options.reply);
|
||||
const promptText = options.prompt?.trim() || `Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
|
||||
|
||||
const sdk = await SandboxAgent.start({
|
||||
spawn: {
|
||||
enabled: true,
|
||||
log: "inherit",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sdk.installAgent(agent);
|
||||
|
||||
const agents = await sdk.listAgents({ config: true });
|
||||
const selectedAgent = agents.agents.find((entry) => entry.id === agent);
|
||||
const configOptions = Array.isArray(selectedAgent?.configOptions)
|
||||
? (selectedAgent.configOptions as Array<{ category?: string; currentValue?: string; options?: unknown[] }>)
|
||||
: [];
|
||||
const modeOption = configOptions.find((option) => option.category === "mode");
|
||||
const availableModes = extractOptionValues(modeOption);
|
||||
const mode = options.mode?.trim() || (typeof modeOption?.currentValue === "string" ? modeOption.currentValue : "") || availableModes[0] || "";
|
||||
|
||||
console.log(`Agent: ${agent}`);
|
||||
console.log(`Mode: ${mode || "(default)"}`);
|
||||
if (availableModes.length > 0) {
|
||||
console.log(`Available modes: ${availableModes.join(", ")}`);
|
||||
}
|
||||
console.log(`Working directory: ${process.cwd()}`);
|
||||
console.log(`Prompt: ${promptText}`);
|
||||
if (autoReply) {
|
||||
console.log(`Automatic permission reply: ${autoReply}`);
|
||||
} else {
|
||||
console.log("Interactive permission replies enabled.");
|
||||
}
|
||||
|
||||
const session = await sdk.createSession({
|
||||
agent,
|
||||
...(mode ? { mode } : {}),
|
||||
sessionInit: {
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
},
|
||||
});
|
||||
|
||||
const rl = autoReply
|
||||
? null
|
||||
: createInterface({
|
||||
input,
|
||||
output,
|
||||
});
|
||||
|
||||
session.onPermissionRequest((request: SessionPermissionRequest) => {
|
||||
void handlePermissionRequest(session, request, autoReply, rl);
|
||||
});
|
||||
|
||||
const response = await session.prompt([{ type: "text", text: promptText }]);
|
||||
console.log(`Prompt finished with stopReason=${response.stopReason}`);
|
||||
|
||||
await rl?.close();
|
||||
} finally {
|
||||
await sdk.dispose();
|
||||
}
|
||||
|
||||
async function handlePermissionRequest(
|
||||
session: {
|
||||
respondPermission(permissionId: string, reply: PermissionReply): Promise<void>;
|
||||
},
|
||||
request: SessionPermissionRequest,
|
||||
auto: PermissionReply | null,
|
||||
rl: ReturnType<typeof createInterface> | null,
|
||||
): Promise<void> {
|
||||
const reply = auto ?? (await promptForReply(request, rl));
|
||||
console.log(`Permission ${reply}: ${request.toolCall.title ?? request.toolCall.toolCallId}`);
|
||||
await session.respondPermission(request.id, reply);
|
||||
}
|
||||
|
||||
async function promptForReply(request: SessionPermissionRequest, rl: ReturnType<typeof createInterface> | null): Promise<PermissionReply> {
|
||||
if (!rl) {
|
||||
return "reject";
|
||||
}
|
||||
|
||||
const title = request.toolCall.title ?? request.toolCall.toolCallId;
|
||||
const available = request.availableReplies;
|
||||
console.log("");
|
||||
console.log(`Permission request: ${title}`);
|
||||
console.log(`Available replies: ${available.join(", ")}`);
|
||||
const answer = (await rl.question("Reply [once|always|reject]: ")).trim().toLowerCase();
|
||||
const parsed = parsePermissionReply(answer);
|
||||
if (parsed && available.includes(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
console.log("Invalid reply, defaulting to reject.");
|
||||
return "reject";
|
||||
}
|
||||
|
||||
function extractOptionValues(option: { options?: unknown[] } | undefined): string[] {
|
||||
if (!option?.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
for (const entry of option.options) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const value = "value" in entry && typeof entry.value === "string" ? entry.value : null;
|
||||
if (value) {
|
||||
values.push(value);
|
||||
continue;
|
||||
}
|
||||
if (!("options" in entry) || !Array.isArray(entry.options)) {
|
||||
continue;
|
||||
}
|
||||
for (const nested of entry.options) {
|
||||
if (!nested || typeof nested !== "object") {
|
||||
continue;
|
||||
}
|
||||
const nestedValue = "value" in nested && typeof nested.value === "string" ? nested.value : null;
|
||||
if (nestedValue) {
|
||||
values.push(nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function parsePermissionReply(value: string | undefined): PermissionReply | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case "once":
|
||||
return "once";
|
||||
case "always":
|
||||
return "always";
|
||||
case "reject":
|
||||
case "deny":
|
||||
return "reject";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(): {
|
||||
agent: string;
|
||||
mode?: string;
|
||||
prompt?: string;
|
||||
reply?: string;
|
||||
} {
|
||||
const argv = process.argv.slice(2);
|
||||
const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
const program = new Command();
|
||||
program
|
||||
.name("permissions")
|
||||
.description("Run a permissions example against an agent session.")
|
||||
.requiredOption("--agent <agent>", "Agent to run, for example 'claude' or 'codex'")
|
||||
.option("--mode <mode>", "Mode to configure for the session (uses agent default if omitted)")
|
||||
.option("--prompt <text>", "Prompt to send after the session starts")
|
||||
.option("--reply <reply>", "Automatically answer permission prompts with once, always, or reject");
|
||||
|
||||
program.parse(normalizedArgv, { from: "user" });
|
||||
return program.opts<{
|
||||
agent: string;
|
||||
mode?: string;
|
||||
prompt?: string;
|
||||
reply?: string;
|
||||
}>();
|
||||
}
|
||||
14
examples/permissions/tsconfig.json
Normal file
14
examples/permissions/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -7,10 +7,6 @@ const persist = new InMemorySessionPersistDriver();
|
|||
console.log("Starting sandbox...");
|
||||
const sandbox = await startDockerSandbox({
|
||||
port: 3000,
|
||||
setupCommands: [
|
||||
"sandbox-agent install-agent claude",
|
||||
"sandbox-agent install-agent codex",
|
||||
],
|
||||
});
|
||||
|
||||
const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist });
|
||||
|
|
|
|||
|
|
@ -16,21 +16,47 @@ if (process.env.DATABASE_URL) {
|
|||
connectionString = process.env.DATABASE_URL;
|
||||
} else {
|
||||
const name = `persist-example-${randomUUID().slice(0, 8)}`;
|
||||
containerId = execFileSync("docker", [
|
||||
"run", "-d", "--rm", "--name", name,
|
||||
"-e", "POSTGRES_USER=postgres", "-e", "POSTGRES_PASSWORD=postgres", "-e", "POSTGRES_DB=sandbox",
|
||||
"-p", "127.0.0.1::5432", "postgres:16-alpine",
|
||||
], { encoding: "utf8" }).trim();
|
||||
containerId = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"-e",
|
||||
"POSTGRES_USER=postgres",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=postgres",
|
||||
"-e",
|
||||
"POSTGRES_DB=sandbox",
|
||||
"-p",
|
||||
"127.0.0.1::5432",
|
||||
"postgres:16-alpine",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
const port = execFileSync("docker", ["port", containerId, "5432/tcp"], { encoding: "utf8" })
|
||||
.trim().split("\n")[0]?.match(/:(\d+)$/)?.[1];
|
||||
.trim()
|
||||
.split("\n")[0]
|
||||
?.match(/:(\d+)$/)?.[1];
|
||||
connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/sandbox`;
|
||||
console.log(`Postgres on port ${port}`);
|
||||
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const c = new Client({ connectionString });
|
||||
try { await c.connect(); await c.query("SELECT 1"); await c.end(); break; }
|
||||
catch { try { await c.end(); } catch {} await delay(250); }
|
||||
try {
|
||||
await c.connect();
|
||||
await c.query("SELECT 1");
|
||||
await c.end();
|
||||
break;
|
||||
} catch {
|
||||
try {
|
||||
await c.end();
|
||||
} catch {}
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,10 +66,6 @@ try {
|
|||
console.log("Starting sandbox...");
|
||||
const sandbox = await startDockerSandbox({
|
||||
port: 3000,
|
||||
setupCommands: [
|
||||
"sandbox-agent install-agent claude",
|
||||
"sandbox-agent install-agent codex",
|
||||
],
|
||||
});
|
||||
|
||||
const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist });
|
||||
|
|
@ -71,6 +93,8 @@ try {
|
|||
await sandbox.cleanup();
|
||||
} finally {
|
||||
if (containerId) {
|
||||
try { execFileSync("docker", ["rm", "-f", containerId], { stdio: "ignore" }); } catch {}
|
||||
try {
|
||||
execFileSync("docker", ["rm", "-f", containerId], { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ const persist = new SQLiteSessionPersistDriver({ filename: "./sessions.db" });
|
|||
console.log("Starting sandbox...");
|
||||
const sandbox = await startDockerSandbox({
|
||||
port: 3000,
|
||||
setupCommands: [
|
||||
"sandbox-agent install-agent claude",
|
||||
"sandbox-agent install-agent codex",
|
||||
],
|
||||
});
|
||||
|
||||
const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist });
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g --silent @sandbox-agent/cli@latest && \
|
||||
sandbox-agent install-agent claude
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
FROM node:22-bookworm-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace root config
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Copy packages needed for the inspector build chain:
|
||||
# inspector -> sandbox-agent SDK -> acp-http-client, cli-shared, persist-indexeddb
|
||||
COPY sdks/typescript/ sdks/typescript/
|
||||
COPY sdks/acp-http-client/ sdks/acp-http-client/
|
||||
COPY sdks/cli-shared/ sdks/cli-shared/
|
||||
COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/
|
||||
COPY sdks/react/ sdks/react/
|
||||
COPY frontend/packages/inspector/ frontend/packages/inspector/
|
||||
COPY docs/openapi.json docs/
|
||||
|
||||
# Create stub package.json for workspace packages referenced in pnpm-workspace.yaml
|
||||
# but not needed for the inspector build (avoids install errors).
|
||||
RUN set -e; for dir in \
|
||||
sdks/cli sdks/gigacode \
|
||||
sdks/persist-postgres sdks/persist-sqlite sdks/persist-rivet \
|
||||
resources/agent-schemas resources/vercel-ai-sdk-schemas \
|
||||
scripts/release scripts/sandbox-testing \
|
||||
examples/shared examples/docker examples/e2b examples/vercel \
|
||||
examples/daytona examples/cloudflare examples/file-system \
|
||||
examples/mcp examples/mcp-custom-tool \
|
||||
examples/skills examples/skills-custom-tool \
|
||||
frontend/packages/website; do \
|
||||
mkdir -p "$dir"; \
|
||||
printf '{"name":"@stub/%s","private":true,"version":"0.0.0"}\n' "$(basename "$dir")" > "$dir/package.json"; \
|
||||
done; \
|
||||
for parent in sdks/cli/platforms sdks/gigacode/platforms; do \
|
||||
for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do \
|
||||
mkdir -p "$parent/$plat"; \
|
||||
printf '{"name":"@stub/%s-%s","private":true,"version":"0.0.0"}\n' "$(basename "$parent")" "$plat" > "$parent/$plat/package.json"; \
|
||||
done; \
|
||||
done
|
||||
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
ENV SKIP_OPENAPI_GEN=1
|
||||
RUN pnpm --filter sandbox-agent build && \
|
||||
pnpm --filter @sandbox-agent/inspector build
|
||||
|
||||
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 --from=frontend /build/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 && \
|
||||
cp target/release/sandbox-agent /sandbox-agent
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
|
||||
RUN sandbox-agent install-agent claude
|
||||
|
|
@ -6,10 +6,10 @@ import { PassThrough } from "node:stream";
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXAMPLE_IMAGE = "sandbox-agent-examples:latest";
|
||||
const EXAMPLE_IMAGE_DEV = "sandbox-agent-examples-dev:latest";
|
||||
const DOCKERFILE_DIR = path.resolve(__dirname, "..");
|
||||
const REPO_ROOT = path.resolve(DOCKERFILE_DIR, "../..");
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
|
||||
/** Pre-built Docker image with all agents installed. */
|
||||
export const FULL_IMAGE = "rivetdev/sandbox-agent:0.3.1-full";
|
||||
|
||||
export interface DockerSandboxOptions {
|
||||
/** Container port used by sandbox-agent inside Docker. */
|
||||
|
|
@ -18,7 +18,7 @@ export interface DockerSandboxOptions {
|
|||
hostPort?: number;
|
||||
/** Additional shell commands to run before starting sandbox-agent. */
|
||||
setupCommands?: string[];
|
||||
/** Docker image to use. Defaults to the pre-built sandbox-agent-examples image. */
|
||||
/** Docker image to use. Defaults to the pre-built full image. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ const DIRECT_CREDENTIAL_KEYS = [
|
|||
|
||||
function stripShellQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
||||
|
|
@ -107,11 +107,7 @@ function collectCredentialEnv(): Record<string, string> {
|
|||
const merged: Record<string, string> = {};
|
||||
let extracted: Record<string, string> = {};
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"sandbox-agent",
|
||||
["credentials", "extract-env"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
const output = execFileSync("sandbox-agent", ["credentials", "extract-env"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
extracted = parseExtractedCredentials(output);
|
||||
} catch {
|
||||
// Fall back to direct env vars if extraction is unavailable.
|
||||
|
|
@ -132,43 +128,34 @@ function shellSingleQuotedLiteral(value: string): string {
|
|||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(
|
||||
/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g,
|
||||
"",
|
||||
);
|
||||
return value.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g, "");
|
||||
}
|
||||
|
||||
async function ensureExampleImage(_docker: Docker): Promise<string> {
|
||||
const dev = !!process.env.SANDBOX_AGENT_DEV;
|
||||
const imageName = dev ? EXAMPLE_IMAGE_DEV : EXAMPLE_IMAGE;
|
||||
|
||||
if (dev) {
|
||||
console.log(" Building sandbox image from source (may take a while, only runs once)...");
|
||||
async function ensureImage(docker: Docker, image: string): Promise<void> {
|
||||
if (process.env.SANDBOX_AGENT_DEV) {
|
||||
console.log(" Building sandbox image from source (may take a while)...");
|
||||
try {
|
||||
execFileSync("docker", [
|
||||
"build", "-t", imageName,
|
||||
"-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"),
|
||||
REPO_ROOT,
|
||||
], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : "";
|
||||
throw new Error(`Failed to build sandbox image: ${stderr}`);
|
||||
}
|
||||
} else {
|
||||
console.log(" Building sandbox image (may take a while, only runs once)...");
|
||||
try {
|
||||
execFileSync("docker", ["build", "-t", imageName, DOCKERFILE_DIR], {
|
||||
execFileSync("docker", ["build", "-t", image, "-f", path.join(REPO_ROOT, "docker/runtime/Dockerfile.full"), REPO_ROOT], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : "";
|
||||
throw new Error(`Failed to build sandbox image: ${stderr}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return imageName;
|
||||
try {
|
||||
await docker.getImage(image).inspect();
|
||||
} catch {
|
||||
console.log(` Pulling ${image}...`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||
if (err) return reject(err);
|
||||
docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -177,8 +164,7 @@ async function ensureExampleImage(_docker: Docker): Promise<string> {
|
|||
*/
|
||||
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
|
||||
const { port, hostPort } = opts;
|
||||
const useCustomImage = !!opts.image;
|
||||
let image = opts.image ?? EXAMPLE_IMAGE;
|
||||
const image = opts.image ?? FULL_IMAGE;
|
||||
// TODO: Replace setupCommands shell bootstrapping with native sandbox-agent exec API once available.
|
||||
const setupCommands = [...(opts.setupCommands ?? [])];
|
||||
const credentialEnv = collectCredentialEnv();
|
||||
|
|
@ -208,35 +194,15 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
if (useCustomImage) {
|
||||
try {
|
||||
await docker.getImage(image).inspect();
|
||||
} catch {
|
||||
console.log(` Pulling ${image}...`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||
if (err) return reject(err);
|
||||
docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
image = await ensureExampleImage(docker);
|
||||
}
|
||||
await ensureImage(docker, image);
|
||||
|
||||
const bootCommands = [
|
||||
...setupCommands,
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`,
|
||||
];
|
||||
const bootCommands = [...setupCommands, `sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`];
|
||||
|
||||
const container = await docker.createContainer({
|
||||
Image: image,
|
||||
WorkingDir: "/root",
|
||||
WorkingDir: "/home/sandbox",
|
||||
Cmd: ["sh", "-c", bootCommands.join(" && ")],
|
||||
Env: [
|
||||
...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`),
|
||||
...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`),
|
||||
],
|
||||
Env: [...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)],
|
||||
ExposedPorts: { [`${port}/tcp`]: {} },
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
|
|
@ -246,12 +212,12 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
await container.start();
|
||||
|
||||
const logChunks: string[] = [];
|
||||
const startupLogs = await container.logs({
|
||||
const startupLogs = (await container.logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
since: 0,
|
||||
}) as NodeJS.ReadableStream;
|
||||
})) as NodeJS.ReadableStream;
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
stdoutStream.on("data", (chunk) => {
|
||||
|
|
@ -263,7 +229,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
docker.modem.demuxStream(startupLogs, stdoutStream, stderrStream);
|
||||
const stopStartupLogs = () => {
|
||||
const stream = startupLogs as NodeJS.ReadableStream & { destroy?: () => void };
|
||||
try { stream.destroy?.(); } catch {}
|
||||
try {
|
||||
stream.destroy?.();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const inspect = await container.inspect();
|
||||
|
|
@ -279,8 +247,12 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
|
||||
const cleanup = async () => {
|
||||
stopStartupLogs();
|
||||
try { await container.stop({ t: 5 }); } catch {}
|
||||
try { await container.remove({ force: true }); } catch {}
|
||||
try {
|
||||
await container.stop({ t: 5 });
|
||||
} catch {}
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
|
|
|
|||
|
|
@ -41,15 +41,7 @@ export function buildInspectorUrl({
|
|||
return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`;
|
||||
}
|
||||
|
||||
export function logInspectorUrl({
|
||||
baseUrl,
|
||||
token,
|
||||
headers,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
}): void {
|
||||
export function logInspectorUrl({ baseUrl, token, headers }: { baseUrl: string; token?: string; headers?: Record<string, string> }): void {
|
||||
console.log(`Inspector: ${buildInspectorUrl({ baseUrl, token, headers })}`);
|
||||
}
|
||||
|
||||
|
|
@ -84,10 +76,7 @@ export function generateSessionId(): string {
|
|||
export function detectAgent(): string {
|
||||
if (process.env.SANDBOX_AGENT) return process.env.SANDBOX_AGENT;
|
||||
const hasClaude = Boolean(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.CLAUDE_API_KEY ||
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
);
|
||||
const openAiLikeKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY || "";
|
||||
const hasCodexApiKey = openAiLikeKey.startsWith("sk-");
|
||||
|
|
|
|||
|
|
@ -23,25 +23,16 @@ console.log("Uploading script and skill file...");
|
|||
const client = await SandboxAgent.connect({ baseUrl });
|
||||
|
||||
const script = await fs.promises.readFile(scriptFile);
|
||||
const scriptResult = await client.writeFsFile(
|
||||
{ path: "/opt/skills/random-number/random-number.cjs" },
|
||||
script,
|
||||
);
|
||||
const scriptResult = await client.writeFsFile({ path: "/opt/skills/random-number/random-number.cjs" }, script);
|
||||
console.log(` Script: ${scriptResult.path} (${scriptResult.bytesWritten} bytes)`);
|
||||
|
||||
const skillMd = await fs.promises.readFile(path.resolve(__dirname, "../SKILL.md"));
|
||||
const skillResult = await client.writeFsFile(
|
||||
{ path: "/opt/skills/random-number/SKILL.md" },
|
||||
skillMd,
|
||||
);
|
||||
const skillResult = await client.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skillMd);
|
||||
console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`);
|
||||
|
||||
// Configure the uploaded skill.
|
||||
console.log("Configuring custom skill...");
|
||||
await client.setSkillsConfig(
|
||||
{ directory: "/", skillName: "random-number" },
|
||||
{ sources: [{ type: "local", source: "/opt/skills/random-number" }] },
|
||||
);
|
||||
await client.setSkillsConfig({ directory: "/", skillName: "random-number" }, { sources: [{ type: "local", source: "/opt/skills/random-number" }] });
|
||||
|
||||
// Create a session.
|
||||
console.log("Creating session with custom skill...");
|
||||
|
|
@ -52,4 +43,7 @@ console.log(' Try: "generate a random number between 1 and 100"');
|
|||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(keepAlive);
|
||||
cleanup().then(() => process.exit(0));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,4 +22,7 @@ console.log(' Try: "How do I start sandbox-agent?"');
|
|||
console.log(" Press Ctrl+C to stop.");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(keepAlive);
|
||||
cleanup().then(() => process.exit(0));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@ describe("vercel example", () => {
|
|||
await cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
|
@ -14,11 +11,6 @@
|
|||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,230 +0,0 @@
|
|||
# Project Instructions
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Do not preserve legacy compatibility. Implement the best current architecture, even if breaking.
|
||||
|
||||
## Language Policy
|
||||
|
||||
Use TypeScript for all source code.
|
||||
|
||||
- Never add raw JavaScript source files (`.js`, `.mjs`, `.cjs`).
|
||||
- Prefer `.ts`/`.tsx` for runtime code, scripts, tests, and tooling.
|
||||
- If touching old JavaScript, migrate it to TypeScript instead of extending it.
|
||||
|
||||
## Monorepo + Tooling
|
||||
|
||||
Use `pnpm` workspaces and Turborepo.
|
||||
|
||||
- Workspace root uses `pnpm-workspace.yaml` and `turbo.json`.
|
||||
- Packages live in `packages/*`.
|
||||
- `core` is renamed to `shared`.
|
||||
- `packages/cli` is disabled and excluded from active workspace validation.
|
||||
- Integrations and providers live under `packages/backend/src/{integrations,providers}`.
|
||||
|
||||
## CLI Status
|
||||
|
||||
- `packages/cli` is fully disabled for active development.
|
||||
- Do not implement new behavior in `packages/cli` unless explicitly requested.
|
||||
- Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`.
|
||||
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@openhandoff/cli`.
|
||||
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
|
||||
|
||||
## Common Commands
|
||||
|
||||
- Install deps: `pnpm install`
|
||||
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
|
||||
- Start the full dev stack: `just factory-dev`
|
||||
- Start the local production-build preview stack: `just factory-preview`
|
||||
- Start only the backend locally: `just factory-backend-start`
|
||||
- Start only the frontend locally: `pnpm --filter @openhandoff/frontend dev`
|
||||
- Start the frontend against the mock workbench client: `OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend dev`
|
||||
- Stop the compose dev stack: `just factory-dev-down`
|
||||
- Tail compose logs: `just factory-dev-logs`
|
||||
- Stop the preview stack: `just factory-preview-down`
|
||||
- Tail preview logs: `just factory-preview-logs`
|
||||
|
||||
## Frontend + Client Boundary
|
||||
|
||||
- Keep a browser-friendly GUI implementation aligned with the TUI interaction model wherever possible.
|
||||
- Do not import `rivetkit` directly in CLI or GUI packages. RivetKit client access must stay isolated inside `packages/client`.
|
||||
- All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`.
|
||||
- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../api/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior.
|
||||
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
|
||||
- Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up.
|
||||
- Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain.
|
||||
- When making UI changes, verify the live flow with `agent-browser`, take screenshots of the updated UI, and offer to open those screenshots in Preview when you finish.
|
||||
- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow.
|
||||
- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
- Runtime is Bun-native.
|
||||
- Use Bun for CLI/backend execution paths and process spawning.
|
||||
- Do not add Node compatibility fallbacks for OpenTUI/runtime execution.
|
||||
|
||||
## Defensive Error Handling
|
||||
|
||||
- Write code defensively: validate assumptions at boundaries and state transitions.
|
||||
- If the system reaches an unexpected state, raise an explicit error with actionable context.
|
||||
- Do not fail silently, swallow errors, or auto-ignore inconsistent data.
|
||||
- Prefer fail-fast behavior over hidden degradation when correctness is uncertain.
|
||||
|
||||
## RivetKit Dependency Policy
|
||||
|
||||
For all Rivet/RivetKit implementation:
|
||||
|
||||
1. Use SQLite + Drizzle for persistent state.
|
||||
2. SQLite is **per actor instance** (per actor key), not a shared backend-global database:
|
||||
- Each actor instance gets its own SQLite DB.
|
||||
- Schema design should assume a single actor instance owns the entire DB.
|
||||
- Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
|
||||
- Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, 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. Do not use published RivetKit npm packages.
|
||||
5. RivetKit is linked via pnpm `link:` protocol to `../rivet/rivetkit-typescript/packages/rivetkit`. Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the rivet workspace.
|
||||
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`
|
||||
- Dev worktree note: when working on RivetKit fixes for this repo, prefer the dedicated local checkout above and link to `../rivet-checkout/rivetkit-typescript/packages/rivetkit`.
|
||||
6. Before using, build RivetKit in the rivet repo:
|
||||
```bash
|
||||
cd ../rivet-checkout/rivetkit-typescript
|
||||
pnpm install
|
||||
pnpm build -F rivetkit
|
||||
```
|
||||
|
||||
## Inspector HTTP API (Workflow Debugging)
|
||||
|
||||
- The Inspector HTTP routes come from RivetKit `feat: inspector http api (#4144)` and are served from the RivetKit manager endpoint (not `/api/rivet`).
|
||||
- Resolve manager endpoint from backend metadata:
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint'
|
||||
```
|
||||
- List actors:
|
||||
- `GET {manager}/actors?name=handoff`
|
||||
- Inspector endpoints (path prefix: `/gateway/{actorId}/inspector`):
|
||||
- `GET /state`
|
||||
- `PATCH /state`
|
||||
- `GET /connections`
|
||||
- `GET /rpcs`
|
||||
- `POST /action/{name}`
|
||||
- `GET /queue?limit=50`
|
||||
- `GET /traces?startMs=0&endMs=<ms>&limit=1000`
|
||||
- `GET /workflow-history`
|
||||
- `GET /summary`
|
||||
- Auth:
|
||||
- Production: send `Authorization: Bearer $RIVET_INSPECTOR_TOKEN`.
|
||||
- Development: auth can be skipped when no inspector token is configured.
|
||||
- Handoff workflow quick inspect:
|
||||
```bash
|
||||
MGR="$(curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint')"
|
||||
HID="7df7656e-bbd2-4b8c-bf0f-30d4df2f619a"
|
||||
AID="$(curl -sS "$MGR/actors?name=handoff" \
|
||||
| jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/handoff/\($hid)")) | .actor_id' \
|
||||
| head -n1)"
|
||||
curl -sS "$MGR/gateway/$AID/inspector/workflow-history" | jq .
|
||||
curl -sS "$MGR/gateway/$AID/inspector/summary" | jq .
|
||||
```
|
||||
- If inspector routes return `404 Not Found (RivetKit)`, the running backend is on a RivetKit build that predates `#4144`; rebuild linked RivetKit and restart backend.
|
||||
|
||||
## Workspace + Actor Rules
|
||||
|
||||
- Everything is scoped to a workspace.
|
||||
- Workspace resolution order: `--workspace` flag -> config default -> `"default"`.
|
||||
- `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator).
|
||||
- Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`).
|
||||
- CLI/TUI/GUI must use `@openhandoff/client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`.
|
||||
- Do not add custom backend REST endpoints (no `/v1/*` shim layer).
|
||||
- We own the sandbox-agent project; treat sandbox-agent defects as first-party bugs and fix them instead of working around them.
|
||||
- Keep strict single-writer ownership: each table/row has exactly one actor writer.
|
||||
- Parent actors (`workspace`, `project`, `handoff`, `history`, `sandbox-instance`) use command-only loops with no timeout.
|
||||
- Periodic syncing lives in dedicated child actors with one timeout cadence each.
|
||||
- Actor handle policy:
|
||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
||||
- Use create semantics only on explicit provisioning/create paths where creating a new actor instance is intended.
|
||||
- `getOrCreate` is a last resort for create paths when an explicit create API is unavailable; never use it in read/command paths.
|
||||
- For long-lived cross-actor links (for example sandbox/session runtime access), persist actor identity (`actorId`) and keep a fallback lookup path by actor id.
|
||||
- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/openhandoff/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed).
|
||||
- RivetKit actor `c.state` is durable, but in Docker it is stored under `/root/.local/share/rivetkit`. If that path is not persisted, actor state-derived indexes (for example, in `project` actor state) can be lost after container recreation even when other data still exists.
|
||||
- Workflow history divergence policy:
|
||||
- Production: never auto-delete actor state to resolve `HistoryDivergedError`; ship explicit workflow migrations (`ctx.removed(...)`, step compatibility).
|
||||
- Development: manual local state reset is allowed as an operator recovery path when migrations are not yet available.
|
||||
- Storage rule of thumb:
|
||||
- Put simple metadata in `c.state` (KV state): small scalars and identifiers like `{ handoffId }`, `{ repoId }`, booleans, counters, timestamps, status strings.
|
||||
- If it grows beyond trivial (arrays, maps, histories, query/filter needs, relational consistency), use SQLite + Drizzle in `c.db`.
|
||||
|
||||
## Testing Policy
|
||||
|
||||
- Never use vitest mocks (`vi.mock`, `vi.spyOn`, `vi.fn`). Instead, define driver interfaces for external I/O and pass test implementations via the actor runtime context.
|
||||
- All external service calls (git CLI, GitHub CLI, sandbox-agent HTTP, tmux) must go through the `BackendDriver` interface on the runtime context.
|
||||
- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`.
|
||||
- End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime.
|
||||
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
|
||||
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
||||
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
|
||||
- Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E.
|
||||
- Do not keep large browser E2E suites around in a broken state. If a frontend browser E2E is not maintained and producing signal, remove it until it can be replaced with a reliable test.
|
||||
|
||||
## Config
|
||||
|
||||
- Keep config path at `~/.config/openhandoff/config.toml`.
|
||||
- Evolve properties in place; do not move config location.
|
||||
|
||||
## Project Guidance
|
||||
|
||||
Project-specific guidance lives in `README.md`, `CONTRIBUTING.md`, and the relevant files under `research/`.
|
||||
|
||||
Keep those updated when:
|
||||
|
||||
- Commands change
|
||||
- Configuration options change
|
||||
- Architecture changes
|
||||
- Plugins/providers change
|
||||
- Actor ownership changes
|
||||
|
||||
## Friction Logs
|
||||
|
||||
Track friction at:
|
||||
|
||||
- `research/friction/rivet.mdx`
|
||||
- `research/friction/sandbox-agent.mdx`
|
||||
- `research/friction/sandboxes.mdx`
|
||||
- `research/friction/general.mdx`
|
||||
|
||||
Category mapping:
|
||||
|
||||
- `rivet`: Rivet/RivetKit runtime, actor model, queues, keys
|
||||
- `sandbox-agent`: sandbox-agent SDK/API behavior
|
||||
- `sandboxes`: provider implementations (worktree/daytona/etc)
|
||||
- `general`: everything else
|
||||
|
||||
Each entry must include:
|
||||
|
||||
- Date (`YYYY-MM-DD`)
|
||||
- Commit SHA (or `uncommitted`)
|
||||
- What you were implementing
|
||||
- Friction/issue
|
||||
- Attempted fix/workaround and outcome
|
||||
|
||||
## History Events
|
||||
|
||||
Log notable workflow changes to `events` so `hf history` remains complete:
|
||||
|
||||
- create
|
||||
- attach
|
||||
- push/sync/merge
|
||||
- archive/kill
|
||||
- status transitions
|
||||
- PR state transitions
|
||||
|
||||
## Validation After Changes
|
||||
|
||||
Always run and fix failures:
|
||||
|
||||
```bash
|
||||
pnpm -w typecheck
|
||||
pnpm -w build
|
||||
pnpm -w test
|
||||
```
|
||||
|
||||
After making code changes, always update the dev server before declaring the work complete. If the dev stack is running through Docker Compose, restart or recreate the relevant dev services so the running app reflects the latest code.
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim AS base
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
COPY packages/backend/package.json packages/backend/package.json
|
||||
COPY packages/rivetkit-vendor/rivetkit/package.json packages/rivetkit-vendor/rivetkit/package.json
|
||||
COPY packages/rivetkit-vendor/workflow-engine/package.json packages/rivetkit-vendor/workflow-engine/package.json
|
||||
COPY packages/rivetkit-vendor/traces/package.json packages/rivetkit-vendor/traces/package.json
|
||||
COPY packages/rivetkit-vendor/sqlite-vfs/package.json packages/rivetkit-vendor/sqlite-vfs/package.json
|
||||
COPY packages/rivetkit-vendor/sqlite-vfs-linux-x64/package.json packages/rivetkit-vendor/sqlite-vfs-linux-x64/package.json
|
||||
COPY packages/rivetkit-vendor/sqlite-vfs-linux-arm64/package.json packages/rivetkit-vendor/sqlite-vfs-linux-arm64/package.json
|
||||
COPY packages/rivetkit-vendor/sqlite-vfs-darwin-arm64/package.json packages/rivetkit-vendor/sqlite-vfs-darwin-arm64/package.json
|
||||
COPY packages/rivetkit-vendor/sqlite-vfs-darwin-x64/package.json packages/rivetkit-vendor/sqlite-vfs-darwin-x64/package.json
|
||||
COPY packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json
|
||||
COPY packages/rivetkit-vendor/runner/package.json packages/rivetkit-vendor/runner/package.json
|
||||
COPY packages/rivetkit-vendor/runner-protocol/package.json packages/rivetkit-vendor/runner-protocol/package.json
|
||||
COPY packages/rivetkit-vendor/virtual-websocket/package.json packages/rivetkit-vendor/virtual-websocket/package.json
|
||||
RUN pnpm fetch --frozen-lockfile --filter @openhandoff/backend...
|
||||
|
||||
FROM base AS build
|
||||
COPY --from=deps /pnpm/store /pnpm/store
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile --prefer-offline --filter @openhandoff/backend...
|
||||
RUN pnpm --filter @openhandoff/shared build
|
||||
RUN pnpm --filter @openhandoff/backend build
|
||||
RUN pnpm --filter @openhandoff/backend deploy --prod --legacy /out
|
||||
|
||||
FROM oven/bun:1.2 AS runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV HOME=/home/handoff
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
gh \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN addgroup --system --gid 1001 handoff \
|
||||
&& adduser --system --uid 1001 --home /home/handoff --ingroup handoff handoff \
|
||||
&& mkdir -p /home/handoff \
|
||||
&& chown -R handoff:handoff /home/handoff /app
|
||||
COPY --from=build /out ./
|
||||
USER handoff
|
||||
EXPOSE 7741
|
||||
CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"]
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
name: openhandoff
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: factory/docker/backend.dev.Dockerfile
|
||||
image: openhandoff-backend-dev
|
||||
working_dir: /app
|
||||
environment:
|
||||
HF_BACKEND_HOST: "0.0.0.0"
|
||||
HF_BACKEND_PORT: "7741"
|
||||
HF_RIVET_MANAGER_PORT: "8750"
|
||||
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
|
||||
# Pass through credentials needed for agent execution + PR creation in dev/e2e.
|
||||
# Do not hardcode secrets; set these in your environment when starting compose.
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
# sandbox-agent codex plugin currently expects CODEX_API_KEY. Map from OPENAI_API_KEY for convenience.
|
||||
CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}"
|
||||
# Support either GITHUB_TOKEN or GITHUB_PAT in local env files.
|
||||
GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}"
|
||||
GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}"
|
||||
DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}"
|
||||
DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}"
|
||||
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"
|
||||
HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}"
|
||||
ports:
|
||||
- "7741:7741"
|
||||
# RivetKit manager (used by browser clients after /api/rivet metadata redirect in dev)
|
||||
- "8750:8750"
|
||||
volumes:
|
||||
- "..:/app"
|
||||
# The linked RivetKit checkout resolves from factory packages to /handoff/rivet-checkout in-container.
|
||||
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
|
||||
# Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev.
|
||||
- "${HOME}/.codex:/root/.codex"
|
||||
# Keep backend dependency installs Linux-native instead of using host node_modules.
|
||||
- "openhandoff_backend_root_node_modules:/app/node_modules"
|
||||
- "openhandoff_backend_backend_node_modules:/app/factory/packages/backend/node_modules"
|
||||
- "openhandoff_backend_shared_node_modules:/app/factory/packages/shared/node_modules"
|
||||
- "openhandoff_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules"
|
||||
- "openhandoff_backend_typescript_node_modules:/app/sdks/typescript/node_modules"
|
||||
- "openhandoff_backend_pnpm_store:/root/.local/share/pnpm/store"
|
||||
# Persist backend-managed local git clones across container restarts.
|
||||
- "openhandoff_git_repos:/root/.local/share/openhandoff/repos"
|
||||
# Persist RivetKit local storage across container restarts.
|
||||
- "openhandoff_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: factory/docker/frontend.dev.Dockerfile
|
||||
working_dir: /app
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
HOME: "/tmp"
|
||||
HF_BACKEND_HTTP: "http://backend:7741"
|
||||
ports:
|
||||
- "4173:4173"
|
||||
volumes:
|
||||
- "..:/app"
|
||||
# Ensure logs in .openhandoff/ persist on the host even if we change source mounts later.
|
||||
- "./.openhandoff:/app/factory/.openhandoff"
|
||||
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
|
||||
# Use Linux-native workspace dependencies inside the container instead of host node_modules.
|
||||
- "openhandoff_node_modules:/app/node_modules"
|
||||
- "openhandoff_client_node_modules:/app/factory/packages/client/node_modules"
|
||||
- "openhandoff_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules"
|
||||
- "openhandoff_frontend_node_modules:/app/factory/packages/frontend/node_modules"
|
||||
- "openhandoff_shared_node_modules:/app/factory/packages/shared/node_modules"
|
||||
- "openhandoff_pnpm_store:/tmp/.local/share/pnpm/store"
|
||||
|
||||
volumes:
|
||||
openhandoff_backend_root_node_modules: {}
|
||||
openhandoff_backend_backend_node_modules: {}
|
||||
openhandoff_backend_shared_node_modules: {}
|
||||
openhandoff_backend_persist_rivet_node_modules: {}
|
||||
openhandoff_backend_typescript_node_modules: {}
|
||||
openhandoff_backend_pnpm_store: {}
|
||||
openhandoff_git_repos: {}
|
||||
openhandoff_rivetkit_storage: {}
|
||||
openhandoff_node_modules: {}
|
||||
openhandoff_client_node_modules: {}
|
||||
openhandoff_frontend_errors_node_modules: {}
|
||||
openhandoff_frontend_node_modules: {}
|
||||
openhandoff_shared_node_modules: {}
|
||||
openhandoff_pnpm_store: {}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM oven/bun:1.3
|
||||
|
||||
ARG GIT_SPICE_VERSION=v0.23.0
|
||||
ARG SANDBOX_AGENT_VERSION=0.3.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gh \
|
||||
nodejs \
|
||||
npm \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g pnpm@10.28.2
|
||||
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) spice_arch="x86_64" ;; \
|
||||
arm64) spice_arch="aarch64" ;; \
|
||||
*) echo "Unsupported architecture for git-spice: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
tmpdir="$(mktemp -d)"; \
|
||||
curl -fsSL "https://github.com/abhinav/git-spice/releases/download/${GIT_SPICE_VERSION}/git-spice.Linux-${spice_arch}.tar.gz" -o "${tmpdir}/git-spice.tgz"; \
|
||||
tar -xzf "${tmpdir}/git-spice.tgz" -C "${tmpdir}"; \
|
||||
install -m 0755 "${tmpdir}/gs" /usr/local/bin/gs; \
|
||||
ln -sf /usr/local/bin/gs /usr/local/bin/git-spice; \
|
||||
rm -rf "${tmpdir}"
|
||||
|
||||
RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh" | sh
|
||||
|
||||
ENV PATH="/root/.local/bin:${PATH}"
|
||||
ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @openhandoff/backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Backend Notes
|
||||
|
||||
## Actor Hierarchy
|
||||
|
||||
Keep the backend actor tree aligned with this shape unless we explicitly decide to change it:
|
||||
|
||||
```text
|
||||
WorkspaceActor
|
||||
├─ HistoryActor(workspace-scoped global feed)
|
||||
├─ ProjectActor(repo)
|
||||
│ ├─ ProjectBranchSyncActor
|
||||
│ ├─ ProjectPrSyncActor
|
||||
│ └─ HandoffActor(handoff)
|
||||
│ ├─ HandoffSessionActor(session) × N
|
||||
│ │ └─ SessionStatusSyncActor(session) × 0..1
|
||||
│ └─ Handoff-local workbench state
|
||||
└─ SandboxInstanceActor(providerId, sandboxId) × N
|
||||
```
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
- `WorkspaceActor` is the workspace coordinator and lookup/index owner.
|
||||
- `HistoryActor` is workspace-scoped. There is one workspace-level history feed.
|
||||
- `ProjectActor` is the repo coordinator and owns repo-local caches/indexes.
|
||||
- `HandoffActor` is one branch. Treat `1 handoff = 1 branch` once branch assignment is finalized.
|
||||
- `HandoffActor` can have many sessions.
|
||||
- `HandoffActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time.
|
||||
- Session unread state and draft prompts are backend-owned workbench state, not frontend-local state.
|
||||
- Branch rename is a real git operation, not just metadata.
|
||||
- `SandboxInstanceActor` stays separate from `HandoffActor`; handoffs/sessions reference it by identity.
|
||||
- Sync actors are polling workers only. They feed parent actors and should not become the source of truth.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Keep this file up to date whenever actor ownership, hierarchy, or lifecycle responsibilities change.
|
||||
- If the real actor tree diverges from this document, update this document in the same change.
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import type { HandoffStatus, ProviderId } from "@openhandoff/shared";
|
||||
|
||||
export interface HandoffCreatedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
providerId: ProviderId;
|
||||
branchName: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface HandoffStatusEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
status: HandoffStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectSnapshotEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AgentStartedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentIdleEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentErrorEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PrCreatedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
prNumber: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrClosedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
prNumber: number;
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
export interface PrReviewEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
prNumber: number;
|
||||
reviewer: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CiStatusChangedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
prNumber: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type HandoffStepName = "auto_commit" | "push" | "pr_submit";
|
||||
export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed";
|
||||
|
||||
export interface HandoffStepEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
step: HandoffStepName;
|
||||
status: HandoffStepStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BranchSwitchedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
export interface SessionAttachedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface BranchSyncedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
branchName: string;
|
||||
strategy: string;
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import {
|
||||
handoffKey,
|
||||
handoffStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
} from "./keys.js";
|
||||
import type { ProviderId } from "@openhandoff/shared";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
return c.client();
|
||||
}
|
||||
|
||||
export async function getOrCreateWorkspace(c: any, workspaceId: string) {
|
||||
return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
createWithInput: workspaceId
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
|
||||
return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
remoteUrl
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getProject(c: any, workspaceId: string, repoId: string) {
|
||||
return actorClient(c).project.get(projectKey(workspaceId, repoId));
|
||||
}
|
||||
|
||||
export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) {
|
||||
return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
|
||||
}
|
||||
|
||||
export async function getOrCreateHandoff(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
createWithInput: Record<string, unknown>
|
||||
) {
|
||||
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), {
|
||||
createWithInput
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateHistory(c: any, workspaceId: string, repoId: string) {
|
||||
return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectPrSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
intervalMs: number
|
||||
) {
|
||||
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectBranchSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
intervalMs: number
|
||||
) {
|
||||
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getSandboxInstance(c: any, workspaceId: string, providerId: ProviderId, sandboxId: string) {
|
||||
return actorClient(c).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
|
||||
}
|
||||
|
||||
export async function getOrCreateSandboxInstance(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
createWithInput: Record<string, unknown>
|
||||
) {
|
||||
return await actorClient(c).sandboxInstance.getOrCreate(
|
||||
sandboxInstanceKey(workspaceId, providerId, sandboxId),
|
||||
{ createWithInput }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateHandoffStatusSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
sandboxId: string,
|
||||
sessionId: string,
|
||||
createWithInput: Record<string, unknown>
|
||||
) {
|
||||
return await actorClient(c).handoffStatusSync.getOrCreate(
|
||||
handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId),
|
||||
{
|
||||
createWithInput
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function selfProjectPrSync(c: any) {
|
||||
return actorClient(c).projectPrSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfProjectBranchSync(c: any) {
|
||||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHandoffStatusSync(c: any) {
|
||||
return actorClient(c).handoffStatusSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHistory(c: any) {
|
||||
return actorClient(c).history.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHandoff(c: any) {
|
||||
return actorClient(c).handoff.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfWorkspace(c: any) {
|
||||
return actorClient(c).workspace.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfProject(c: any) {
|
||||
return actorClient(c).project.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfSandboxInstance(c: any) {
|
||||
return actorClient(c).sandboxInstance.getForId(c.actorId);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type { ProviderId } from "@openhandoff/shared";
|
||||
import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
|
||||
export interface HandoffStatusSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface SetIntervalCommand {
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface HandoffStatusSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "handoff.status_sync.control.start",
|
||||
stop: "handoff.status_sync.control.stop",
|
||||
setInterval: "handoff.status_sync.control.set_interval",
|
||||
force: "handoff.status_sync.control.force"
|
||||
} as const;
|
||||
|
||||
async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> {
|
||||
const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId);
|
||||
const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId });
|
||||
|
||||
const parent = getHandoff(c, c.state.workspaceId, c.state.repoId, c.state.handoffId);
|
||||
await parent.syncWorkbenchSessionStatus({
|
||||
sessionId: c.state.sessionId,
|
||||
status: status.status,
|
||||
at: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
export const handoffStatusSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
[CONTROL.setInterval]: queue(),
|
||||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
},
|
||||
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
handoffId: input.handoffId,
|
||||
providerId: input.providerId,
|
||||
sandboxId: input.sandboxId,
|
||||
sessionId: input.sessionId,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
|
||||
loopName: "handoff-status-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollSessionStatus(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff-status-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const handoffDb = actorSqliteDb({
|
||||
actorName: "handoff",
|
||||
schema,
|
||||
migrations,
|
||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/handoff/db/drizzle",
|
||||
schema: "./src/actors/handoff/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
CREATE TABLE `handoff` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`auto_committed` integer DEFAULT 0,
|
||||
`pushed` integer DEFAULT 0,
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`needs_push` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `handoff_runtime` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`sandbox_id` text,
|
||||
`session_id` text,
|
||||
`switch_target` text,
|
||||
`status_message` text,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ALTER TABLE `handoff` DROP COLUMN `auto_committed`;--> statement-breakpoint
|
||||
ALTER TABLE `handoff` DROP COLUMN `pushed`;--> statement-breakpoint
|
||||
ALTER TABLE `handoff` DROP COLUMN `needs_push`;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
ALTER TABLE `handoff_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE `handoff_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE `handoff_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE `handoff_sandboxes` (
|
||||
`sandbox_id` text PRIMARY KEY NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`switch_target` text NOT NULL,
|
||||
`cwd` text,
|
||||
`status_message` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `handoff_runtime` ADD `active_cwd` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `handoff_sandboxes` (
|
||||
`sandbox_id`,
|
||||
`provider_id`,
|
||||
`switch_target`,
|
||||
`cwd`,
|
||||
`status_message`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
r.`active_sandbox_id`,
|
||||
(SELECT h.`provider_id` FROM `handoff` h WHERE h.`id` = 1),
|
||||
r.`active_switch_target`,
|
||||
r.`active_cwd`,
|
||||
r.`status_message`,
|
||||
COALESCE((SELECT h.`created_at` FROM `handoff` h WHERE h.`id` = 1), r.`updated_at`),
|
||||
r.`updated_at`
|
||||
FROM `handoff_runtime` r
|
||||
WHERE
|
||||
r.`id` = 1
|
||||
AND r.`active_sandbox_id` IS NOT NULL
|
||||
AND r.`active_switch_target` IS NOT NULL
|
||||
ON CONFLICT(`sandbox_id`) DO NOTHING;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
-- Allow handoffs to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE `handoff__new` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO `handoff__new` (
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `handoff`;
|
||||
|
||||
DROP TABLE `handoff`;
|
||||
ALTER TABLE `handoff__new` RENAME TO `handoff`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
-- Fix: make branch_name/title nullable during initial "naming" stage.
|
||||
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
|
||||
-- Rebuild the table again with proper statement breakpoints.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS `handoff__new`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `handoff__new` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO `handoff__new` (
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `handoff`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE `handoff`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE `handoff__new` RENAME TO `handoff`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE `handoff_sandboxes` ADD `sandbox_actor_id` text;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
CREATE TABLE `handoff_workbench_sessions` (
|
||||
`session_id` text PRIMARY KEY NOT NULL,
|
||||
`session_name` text NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`unread` integer DEFAULT 0 NOT NULL,
|
||||
`draft_text` text DEFAULT '' NOT NULL,
|
||||
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
|
||||
`draft_updated_at` integer,
|
||||
`created` integer DEFAULT 1 NOT NULL,
|
||||
`closed` integer DEFAULT 0 NOT NULL,
|
||||
`thinking_since_ms` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9b004d3b-0722-4bb5-a410-d47635db7df3",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"handoff": {
|
||||
"name": "handoff",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"auto_committed": {
|
||||
"name": "auto_committed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"pushed": {
|
||||
"name": "pushed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"needs_push": {
|
||||
"name": "needs_push",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"handoff_runtime": {
|
||||
"name": "handoff_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0fca0f14-69df-4fca-bc52-29e902247909",
|
||||
"prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3",
|
||||
"tables": {
|
||||
"handoff": {
|
||||
"name": "handoff",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"handoff_runtime": {
|
||||
"name": "handoff_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924374665,
|
||||
"tag": "0000_condemned_maria_hill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770947251055,
|
||||
"tag": "0001_rapid_eddie_brock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770948428907,
|
||||
"tag": "0002_lazy_moira_mactaggert",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1771027535276,
|
||||
"tag": "0003_plucky_bran",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1771097651912,
|
||||
"tag": "0004_focused_shuri",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1771370000000,
|
||||
"tag": "0005_sandbox_actor_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
|
||||
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
|
||||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"when": 1770924374665,
|
||||
"tag": "0000_condemned_maria_hill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"when": 1770947251055,
|
||||
"tag": "0001_rapid_eddie_brock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"when": 1770948428907,
|
||||
"tag": "0002_lazy_moira_mactaggert",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"when": 1771027535276,
|
||||
"tag": "0003_plucky_bran",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"when": 1771097651912,
|
||||
"tag": "0004_focused_shuri",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"when": 1771370000000,
|
||||
"tag": "0005_sandbox_actor_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"when": 1773020000000,
|
||||
"tag": "0006_workbench_sessions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`handoff\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`auto_committed\` integer DEFAULT 0,
|
||||
\`pushed\` integer DEFAULT 0,
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`needs_push\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`handoff_runtime\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`sandbox_id\` text,
|
||||
\`session_id\` text,
|
||||
\`switch_target\` text,
|
||||
\`status_message\` text,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `ALTER TABLE \`handoff\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`handoff\` DROP COLUMN \`pushed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`handoff\` DROP COLUMN \`needs_push\`;`,
|
||||
m0002: `ALTER TABLE \`handoff_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE \`handoff_sandboxes\` (
|
||||
\`sandbox_id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`switch_target\` text NOT NULL,
|
||||
\`cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`handoff_runtime\` ADD \`active_cwd\` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO \`handoff_sandboxes\` (
|
||||
\`sandbox_id\`,
|
||||
\`provider_id\`,
|
||||
\`switch_target\`,
|
||||
\`cwd\`,
|
||||
\`status_message\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
r.\`active_sandbox_id\`,
|
||||
(SELECT h.\`provider_id\` FROM \`handoff\` h WHERE h.\`id\` = 1),
|
||||
r.\`active_switch_target\`,
|
||||
r.\`active_cwd\`,
|
||||
r.\`status_message\`,
|
||||
COALESCE((SELECT h.\`created_at\` FROM \`handoff\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
|
||||
r.\`updated_at\`
|
||||
FROM \`handoff_runtime\` r
|
||||
WHERE
|
||||
r.\`id\` = 1
|
||||
AND r.\`active_sandbox_id\` IS NOT NULL
|
||||
AND r.\`active_switch_target\` IS NOT NULL
|
||||
ON CONFLICT(\`sandbox_id\`) DO NOTHING;
|
||||
`,
|
||||
m0003: `-- Allow handoffs to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE \`handoff__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO \`handoff__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`handoff\`;
|
||||
|
||||
DROP TABLE \`handoff\`;
|
||||
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
`,
|
||||
m0004: `-- Fix: make branch_name/title nullable during initial "naming" stage.
|
||||
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
|
||||
-- Rebuild the table again with proper statement breakpoints.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS \`handoff__new\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE \`handoff__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO \`handoff__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`handoff\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE \`handoff\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
`,
|
||||
m0005: `ALTER TABLE \`handoff_sandboxes\` ADD \`sandbox_actor_id\` text;`,
|
||||
m0006: `CREATE TABLE \`handoff_workbench_sessions\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_name\` text NOT NULL,
|
||||
\`model\` text NOT NULL,
|
||||
\`unread\` integer DEFAULT 0 NOT NULL,
|
||||
\`draft_text\` text DEFAULT '' NOT NULL,
|
||||
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
|
||||
\`draft_updated_at\` integer,
|
||||
\`created\` integer DEFAULT 1 NOT NULL,
|
||||
\`closed\` integer DEFAULT 0 NOT NULL,
|
||||
\`thinking_since_ms\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);`,
|
||||
} as const
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per handoff actor instance, so these tables only ever store one row (id=1).
|
||||
export const handoff = sqliteTable("handoff", {
|
||||
id: integer("id").primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
title: text("title"),
|
||||
task: text("task").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
agentType: text("agent_type").default("claude"),
|
||||
prSubmitted: integer("pr_submitted").default(0),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffRuntime = sqliteTable("handoff_runtime", {
|
||||
id: integer("id").primaryKey(),
|
||||
activeSandboxId: text("active_sandbox_id"),
|
||||
activeSessionId: text("active_session_id"),
|
||||
activeSwitchTarget: text("active_switch_target"),
|
||||
activeCwd: text("active_cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffSandboxes = sqliteTable("handoff_sandboxes", {
|
||||
sandboxId: text("sandbox_id").notNull().primaryKey(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
sandboxActorId: text("sandbox_actor_id"),
|
||||
switchTarget: text("switch_target").notNull(),
|
||||
cwd: text("cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffWorkbenchSessions = sqliteTable("handoff_workbench_sessions", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
sessionName: text("session_name").notNull(),
|
||||
model: text("model").notNull(),
|
||||
unread: integer("unread").notNull().default(0),
|
||||
draftText: text("draft_text").notNull().default(""),
|
||||
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
|
||||
draftUpdatedAt: integer("draft_updated_at"),
|
||||
created: integer("created").notNull().default(1),
|
||||
closed: integer("closed").notNull().default(0),
|
||||
thinkingSinceMs: integer("thinking_since_ms"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type {
|
||||
AgentType,
|
||||
HandoffRecord,
|
||||
HandoffWorkbenchChangeModelInput,
|
||||
HandoffWorkbenchRenameInput,
|
||||
HandoffWorkbenchRenameSessionInput,
|
||||
HandoffWorkbenchSetSessionUnreadInput,
|
||||
HandoffWorkbenchSendMessageInput,
|
||||
HandoffWorkbenchUpdateDraftInput,
|
||||
ProviderId
|
||||
} from "@openhandoff/shared";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { selfHandoff } from "../handles.js";
|
||||
import { handoffDb } from "./db/db.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
getWorkbenchHandoff,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchHandoff,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
syncWorkbenchSessionStatus,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
updateWorkbenchDraft
|
||||
} from "./workbench.js";
|
||||
import {
|
||||
HANDOFF_QUEUE_NAMES,
|
||||
handoffWorkflowQueueName,
|
||||
runHandoffWorkflow
|
||||
} from "./workflow/index.js";
|
||||
|
||||
export interface HandoffInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
repoRemote: string;
|
||||
repoLocalPath: string;
|
||||
branchName: string | null;
|
||||
title: string | null;
|
||||
task: string;
|
||||
providerId: ProviderId;
|
||||
agentType: AgentType | null;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
}
|
||||
|
||||
interface InitializeCommand {
|
||||
providerId?: ProviderId;
|
||||
}
|
||||
|
||||
interface HandoffActionCommand {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface HandoffTabCommand {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface HandoffStatusSyncCommand {
|
||||
sessionId: string;
|
||||
status: "running" | "idle" | "error";
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchValueCommand {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSessionTitleCommand {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSessionUnreadCommand {
|
||||
sessionId: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchUpdateDraftCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchChangeModelCommand {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSendMessageCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSessionCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const handoff = actor({
|
||||
db: handoffDb,
|
||||
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000
|
||||
},
|
||||
createState: (_c, input: HandoffInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
handoffId: input.handoffId,
|
||||
repoRemote: input.repoRemote,
|
||||
repoLocalPath: input.repoLocalPath,
|
||||
branchName: input.branchName,
|
||||
title: input.title,
|
||||
task: input.task,
|
||||
providerId: input.providerId,
|
||||
agentType: input.agentType,
|
||||
explicitTitle: input.explicitTitle,
|
||||
explicitBranchName: input.explicitBranchName,
|
||||
initialized: false,
|
||||
previousStatus: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async initialize(c, cmd: InitializeCommand): Promise<HandoffRecord> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
return expectQueueResponse<HandoffRecord>(result);
|
||||
},
|
||||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30 * 60_000,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
});
|
||||
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
|
||||
},
|
||||
|
||||
async switch(c): Promise<{ switchTarget: string }> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.switch"), {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
});
|
||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
||||
},
|
||||
|
||||
async push(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 180_000
|
||||
});
|
||||
},
|
||||
|
||||
async sync(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
});
|
||||
},
|
||||
|
||||
async merge(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
});
|
||||
},
|
||||
|
||||
async archive(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
void self
|
||||
.send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
c.log.warn({
|
||||
msg: "archive command failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async kill(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000
|
||||
});
|
||||
},
|
||||
|
||||
async get(c): Promise<HandoffRecord> {
|
||||
return await getCurrentRecord({ db: c.db, state: c.state });
|
||||
},
|
||||
|
||||
async getWorkbench(c) {
|
||||
return await getWorkbenchHandoff(c);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), {}, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"),
|
||||
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.rename_branch"),
|
||||
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.create_session"),
|
||||
{ ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ tabId: string }>(result);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.rename_session"),
|
||||
{ sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"),
|
||||
{ sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.update_draft"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies HandoffWorkbenchUpdateDraftCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.change_model"),
|
||||
{ sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies HandoffWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.stop_session"),
|
||||
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"),
|
||||
input,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.close_session"),
|
||||
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(c): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, {
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.revert_file"),
|
||||
input,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
run: workflow(runHandoffWorkflow)
|
||||
});
|
||||
|
||||
export { HANDOFF_QUEUE_NAMES };
|
||||
|
|
@ -1,861 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { basename } from "node:path";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import {
|
||||
getOrCreateHandoffStatusSync,
|
||||
getOrCreateProject,
|
||||
getOrCreateWorkspace,
|
||||
getSandboxInstance,
|
||||
} from "../handles.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
|
||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||
|
||||
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
|
||||
await c.db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS handoff_workbench_sessions (
|
||||
session_id text PRIMARY KEY NOT NULL,
|
||||
session_name text NOT NULL,
|
||||
model text NOT NULL,
|
||||
unread integer DEFAULT 0 NOT NULL,
|
||||
draft_text text DEFAULT '' NOT NULL,
|
||||
draft_attachments_json text DEFAULT '[]' NOT NULL,
|
||||
draft_updated_at integer,
|
||||
created integer DEFAULT 1 NOT NULL,
|
||||
closed integer DEFAULT 0 NOT NULL,
|
||||
thinking_since_ms integer,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function defaultModelForAgent(agentType: string | null | undefined) {
|
||||
return agentType === "codex" ? "gpt-4o" : "claude-sonnet-4";
|
||||
}
|
||||
|
||||
function agentKindForModel(model: string) {
|
||||
if (model === "gpt-4o" || model === "o3") {
|
||||
return "Codex";
|
||||
}
|
||||
return "Claude";
|
||||
}
|
||||
|
||||
export function agentTypeForModel(model: string) {
|
||||
if (model === "gpt-4o" || model === "o3") {
|
||||
return "codex";
|
||||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
function repoLabelFromRemote(remoteUrl: string): string {
|
||||
const trimmed = remoteUrl.trim();
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
||||
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return basename(trimmed.replace(/\.git$/, ""));
|
||||
}
|
||||
|
||||
function parseDraftAttachments(value: string | null | undefined): Array<any> {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
|
||||
if (status === "running") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only mark unread when we observe the transition out of an active thinking state.
|
||||
// Repeated idle polls for an already-finished session must not flip unread back on.
|
||||
return Boolean(meta.thinkingSinceMs);
|
||||
}
|
||||
|
||||
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(handoffWorkbenchSessions)
|
||||
.orderBy(asc(handoffWorkbenchSessions.createdAt))
|
||||
.all();
|
||||
const mapped = rows.map((row: any) => ({
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
sessionId: row.sessionId,
|
||||
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
|
||||
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
|
||||
unread: row.unread === 1,
|
||||
created: row.created === 1,
|
||||
closed: row.closed === 1,
|
||||
}));
|
||||
|
||||
if (options?.includeClosed === true) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return mapped.filter((row: any) => row.closed !== true);
|
||||
}
|
||||
|
||||
async function nextSessionName(c: any): Promise<string> {
|
||||
const rows = await listSessionMetaRows(c, { includeClosed: true });
|
||||
return `Session ${rows.length + 1}`;
|
||||
}
|
||||
|
||||
async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const row = await c.db
|
||||
.select()
|
||||
.from(handoffWorkbenchSessions)
|
||||
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
sessionId: row.sessionId,
|
||||
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
|
||||
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
|
||||
unread: row.unread === 1,
|
||||
created: row.created === 1,
|
||||
closed: row.closed === 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSessionMeta(c: any, params: {
|
||||
sessionId: string;
|
||||
model?: string;
|
||||
sessionName?: string;
|
||||
unread?: boolean;
|
||||
}): Promise<any> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const existing = await readSessionMeta(c, params.sessionId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sessionName = params.sessionName ?? (await nextSessionName(c));
|
||||
const model = params.model ?? defaultModelForAgent(c.state.agentType);
|
||||
const unread = params.unread ?? false;
|
||||
|
||||
await c.db
|
||||
.insert(handoffWorkbenchSessions)
|
||||
.values({
|
||||
sessionId: params.sessionId,
|
||||
sessionName,
|
||||
model,
|
||||
unread: unread ? 1 : 0,
|
||||
draftText: "",
|
||||
draftAttachmentsJson: "[]",
|
||||
draftUpdatedAt: null,
|
||||
created: 1,
|
||||
closed: 0,
|
||||
thinkingSinceMs: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
return await readSessionMeta(c, params.sessionId);
|
||||
}
|
||||
|
||||
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
await c.db
|
||||
.update(handoffWorkbenchSessions)
|
||||
.set({
|
||||
...values,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
|
||||
.run();
|
||||
return await readSessionMeta(c, sessionId);
|
||||
}
|
||||
|
||||
async function notifyWorkbenchUpdated(c: any): Promise<void> {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
}
|
||||
|
||||
function shellFragment(parts: string[]): string {
|
||||
return parts.join(" && ");
|
||||
}
|
||||
|
||||
async function executeInSandbox(c: any, params: {
|
||||
sandboxId: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
label: string;
|
||||
}): Promise<{ exitCode: number; result: string }> {
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(c.state.providerId);
|
||||
return await provider.executeCommand({
|
||||
workspaceId: c.state.workspaceId,
|
||||
sandboxId: params.sandboxId,
|
||||
command: `bash -lc ${JSON.stringify(shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command]))}`,
|
||||
label: params.label,
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" | "D" }> {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const status = line.slice(0, 2).trim();
|
||||
const rawPath = line.slice(3).trim();
|
||||
const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() ?? rawPath : rawPath;
|
||||
const type =
|
||||
status.includes("D")
|
||||
? "D"
|
||||
: status.includes("A") || status === "??"
|
||||
? "A"
|
||||
: "M";
|
||||
return { path, type };
|
||||
});
|
||||
}
|
||||
|
||||
function parseNumstat(output: string): Map<string, { added: number; removed: number }> {
|
||||
const map = new Map<string, { added: number; removed: number }>();
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [addedRaw, removedRaw, ...pathParts] = trimmed.split("\t");
|
||||
const path = pathParts.join("\t").trim();
|
||||
if (!path) continue;
|
||||
map.set(path, {
|
||||
added: Number.parseInt(addedRaw ?? "0", 10) || 0,
|
||||
removed: Number.parseInt(removedRaw ?? "0", 10) || 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildFileTree(paths: string[]): Array<any> {
|
||||
const root = {
|
||||
children: new Map<string, any>(),
|
||||
};
|
||||
|
||||
for (const path of paths) {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = parts[index]!;
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
const isDir = index < parts.length - 1;
|
||||
let node = current.children.get(part);
|
||||
if (!node) {
|
||||
node = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
isDir,
|
||||
children: isDir ? new Map<string, any>() : undefined,
|
||||
};
|
||||
current.children.set(part, node);
|
||||
} else if (isDir && !(node.children instanceof Map)) {
|
||||
node.children = new Map<string, any>();
|
||||
}
|
||||
current = node;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNodes(nodes: Iterable<any>): Array<any> {
|
||||
return [...nodes]
|
||||
.map((node) =>
|
||||
node.isDir
|
||||
? {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
isDir: true,
|
||||
children: sortNodes(node.children?.values?.() ?? []),
|
||||
}
|
||||
: {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
isDir: false,
|
||||
},
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (left.isDir !== right.isDir) {
|
||||
return left.isDir ? -1 : 1;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
}
|
||||
|
||||
return sortNodes(root.children.values());
|
||||
}
|
||||
|
||||
async function collectWorkbenchGitState(c: any, record: any) {
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const activeSandbox =
|
||||
activeSandboxId != null
|
||||
? (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null
|
||||
: null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!activeSandboxId || !cwd) {
|
||||
return {
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
};
|
||||
}
|
||||
|
||||
const statusResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: "git status --porcelain=v1 -uall",
|
||||
label: "git status",
|
||||
});
|
||||
if (statusResult.exitCode !== 0) {
|
||||
return {
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
};
|
||||
}
|
||||
|
||||
const statusRows = parseGitStatus(statusResult.result);
|
||||
const numstatResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: "git diff --numstat",
|
||||
label: "git diff numstat",
|
||||
});
|
||||
const numstat = parseNumstat(numstatResult.result);
|
||||
const diffs: Record<string, string> = {};
|
||||
|
||||
for (const row of statusRows) {
|
||||
const diffResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`,
|
||||
label: `git diff ${row.path}`,
|
||||
});
|
||||
diffs[row.path] = diffResult.result;
|
||||
}
|
||||
|
||||
const filesResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: "git ls-files --cached --others --exclude-standard",
|
||||
label: "git ls-files",
|
||||
});
|
||||
const allPaths = filesResult.result
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
fileChanges: statusRows.map((row) => {
|
||||
const counts = numstat.get(row.path) ?? { added: 0, removed: 0 };
|
||||
return {
|
||||
path: row.path,
|
||||
added: counts.added,
|
||||
removed: counts.removed,
|
||||
type: row.type,
|
||||
};
|
||||
}),
|
||||
diffs,
|
||||
fileTree: buildFileTree(allPaths),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSessionTranscript(c: any, record: any, sessionId: string) {
|
||||
const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null;
|
||||
if (!sandboxId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId);
|
||||
const page = await sandbox.listSessionEvents({
|
||||
sessionId,
|
||||
limit: 500,
|
||||
});
|
||||
return page.items.map((event: any) => ({
|
||||
id: event.id,
|
||||
eventIndex: event.eventIndex,
|
||||
sessionId: event.sessionId,
|
||||
createdAt: event.createdAt,
|
||||
connectionId: event.connectionId,
|
||||
sender: event.sender,
|
||||
payload: event.payload,
|
||||
}));
|
||||
}
|
||||
|
||||
async function activeSessionStatus(c: any, record: any, sessionId: string) {
|
||||
if (record.activeSessionId !== sessionId || !record.activeSandboxId) {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const status = await sandbox.sessionStatus({ sessionId });
|
||||
return status.status;
|
||||
}
|
||||
|
||||
async function readPullRequestSummary(c: any, branchName: string | null) {
|
||||
if (!branchName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await getOrCreateProject(
|
||||
c,
|
||||
c.state.workspaceId,
|
||||
c.state.repoId,
|
||||
c.state.repoRemote,
|
||||
);
|
||||
return await project.getPullRequestForBranch({ branchName });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
||||
const record = await getCurrentRecord({ db: c.db, state: c.state });
|
||||
if (record.activeSessionId) {
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: record.activeSessionId,
|
||||
model: defaultModelForAgent(record.agentType),
|
||||
sessionName: "Session 1",
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function getWorkbenchHandoff(c: any): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await collectWorkbenchGitState(c, record);
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
const tabs = [];
|
||||
|
||||
for (const meta of sessions) {
|
||||
const status = await activeSessionStatus(c, record, meta.sessionId);
|
||||
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||
let unread = Boolean(meta.unread);
|
||||
if (thinkingSinceMs && status !== "running") {
|
||||
thinkingSinceMs = null;
|
||||
unread = true;
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
id: meta.id,
|
||||
sessionId: meta.sessionId,
|
||||
sessionName: meta.sessionName,
|
||||
agent: agentKindForModel(meta.model),
|
||||
model: meta.model,
|
||||
status,
|
||||
thinkingSinceMs: status === "running" ? thinkingSinceMs : null,
|
||||
unread,
|
||||
created: Boolean(meta.created),
|
||||
draft: {
|
||||
text: meta.draftText ?? "",
|
||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||
updatedAtMs: meta.draftUpdatedAtMs ?? null,
|
||||
},
|
||||
transcript: await readSessionTranscript(c, record, meta.sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: c.state.handoffId,
|
||||
repoId: c.state.repoId,
|
||||
title: record.title ?? "New Handoff",
|
||||
status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new",
|
||||
repoName: repoLabelFromRemote(c.state.repoRemote),
|
||||
updatedAtMs: record.updatedAt,
|
||||
branch: record.branchName,
|
||||
pullRequest: await readPullRequestSummary(c, record.branchName),
|
||||
tabs,
|
||||
fileChanges: gitState.fileChanges,
|
||||
diffs: gitState.diffs,
|
||||
fileTree: gitState.fileTree,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renameWorkbenchHandoff(c: any, value: string): Promise<void> {
|
||||
const nextTitle = value.trim();
|
||||
if (!nextTitle) {
|
||||
throw new Error("handoff title is required");
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
title: nextTitle,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.run();
|
||||
c.state.title = nextTitle;
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
|
||||
const nextBranch = value.trim();
|
||||
if (!nextBranch) {
|
||||
throw new Error("branch name is required");
|
||||
}
|
||||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.branchName) {
|
||||
throw new Error("cannot rename branch before handoff branch exists");
|
||||
}
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot rename branch without an active sandbox");
|
||||
}
|
||||
const activeSandbox =
|
||||
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
if (!activeSandbox?.cwd) {
|
||||
throw new Error("cannot rename branch without a sandbox cwd");
|
||||
}
|
||||
|
||||
const renameResult = await executeInSandbox(c, {
|
||||
sandboxId: record.activeSandboxId,
|
||||
cwd: activeSandbox.cwd,
|
||||
command: [
|
||||
`git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`,
|
||||
`if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`,
|
||||
`git push origin ${JSON.stringify(nextBranch)}`,
|
||||
`git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`,
|
||||
].join(" && "),
|
||||
label: `git branch -m ${record.branchName} ${nextBranch}`,
|
||||
});
|
||||
if (renameResult.exitCode !== 0) {
|
||||
throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`);
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
branchName: nextBranch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.run();
|
||||
c.state.branchName = nextBranch;
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
await project.registerHandoffBranch({
|
||||
handoffId: c.state.handoffId,
|
||||
branchName: nextBranch,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot create session without an active sandbox");
|
||||
}
|
||||
const activeSandbox =
|
||||
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot create session without a sandbox cwd");
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const created = await sandbox.createSession({
|
||||
prompt: "",
|
||||
cwd,
|
||||
agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)),
|
||||
});
|
||||
if (!created.id) {
|
||||
throw new Error(created.error ?? "sandbox-agent session creation failed");
|
||||
}
|
||||
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: created.id,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return { tabId: created.id };
|
||||
}
|
||||
|
||||
export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise<void> {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("session title is required");
|
||||
}
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
sessionName: trimmed,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: unread ? 1 : 0,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
draftText: text,
|
||||
draftAttachmentsJson: JSON.stringify(attachments),
|
||||
draftUpdatedAt: Date.now(),
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
model,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot send message without an active sandbox");
|
||||
}
|
||||
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const prompt = [
|
||||
text.trim(),
|
||||
...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (!prompt) {
|
||||
throw new Error("message text is required");
|
||||
}
|
||||
|
||||
await sandbox.sendPrompt({
|
||||
sessionId,
|
||||
prompt,
|
||||
notification: true,
|
||||
});
|
||||
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: 0,
|
||||
created: 1,
|
||||
draftText: "",
|
||||
draftAttachmentsJson: "[]",
|
||||
draftUpdatedAt: Date.now(),
|
||||
thinkingSinceMs: Date.now(),
|
||||
});
|
||||
|
||||
await c.db
|
||||
.update(handoffRuntime)
|
||||
.set({
|
||||
activeSessionId: sessionId,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
c,
|
||||
c.state.workspaceId,
|
||||
c.state.repoId,
|
||||
c.state.handoffId,
|
||||
record.activeSandboxId,
|
||||
sessionId,
|
||||
{
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: c.state.handoffId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId,
|
||||
intervalMs: STATUS_SYNC_INTERVAL_MS,
|
||||
},
|
||||
);
|
||||
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.cancelSession({ sessionId });
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function syncWorkbenchSessionStatus(
|
||||
c: any,
|
||||
sessionId: string,
|
||||
status: "running" | "idle" | "error",
|
||||
at: number,
|
||||
): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const meta = await ensureSessionMeta(c, { sessionId });
|
||||
let changed = false;
|
||||
|
||||
if (record.activeSessionId === sessionId) {
|
||||
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
|
||||
if (record.status !== mappedStatus) {
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
status: mappedStatus,
|
||||
updatedAt: at,
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.run();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const statusMessage = `session:${status}`;
|
||||
if (record.statusMessage !== statusMessage) {
|
||||
await c.db
|
||||
.update(handoffRuntime)
|
||||
.set({
|
||||
statusMessage,
|
||||
updatedAt: at,
|
||||
})
|
||||
.where(eq(handoffRuntime.id, 1))
|
||||
.run();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "running") {
|
||||
if (!meta.thinkingSinceMs) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: at,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (meta.thinkingSinceMs) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if (!meta.unread && shouldMarkSessionUnreadForStatus(meta, status)) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: 1,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.destroySession({ sessionId });
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
closed: 1,
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
if (record.activeSessionId === sessionId) {
|
||||
await c.db
|
||||
.update(handoffRuntime)
|
||||
.set({
|
||||
activeSessionId: null,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffRuntime.id, 1))
|
||||
.run();
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function markWorkbenchUnread(c: any): Promise<void> {
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
const latest = sessions[sessions.length - 1];
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
await updateSessionMeta(c, latest.sessionId, {
|
||||
unread: 1,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function publishWorkbenchPr(c: any): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.branchName) {
|
||||
throw new Error("cannot publish PR without a branch");
|
||||
}
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const created = await driver.github.createPr(
|
||||
c.state.repoLocalPath,
|
||||
record.branchName,
|
||||
record.title ?? c.state.task,
|
||||
);
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
prSubmitted: 1,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.run();
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot revert file without an active sandbox");
|
||||
}
|
||||
const activeSandbox =
|
||||
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
if (!activeSandbox?.cwd) {
|
||||
throw new Error("cannot revert file without a sandbox cwd");
|
||||
}
|
||||
|
||||
const result = await executeInSandbox(c, {
|
||||
sandboxId: record.activeSandboxId,
|
||||
cwd: activeSandbox.cwd,
|
||||
command: `if git ls-files --error-unmatch -- ${JSON.stringify(path)} >/dev/null 2>&1; then git restore --staged --worktree -- ${JSON.stringify(path)} || git checkout -- ${JSON.stringify(path)}; else rm -f ${JSON.stringify(path)}; fi`,
|
||||
label: `git restore ${path}`,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateHandoffStatusSync } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setHandoffState } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.activeSandboxId
|
||||
? record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null
|
||||
: null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const target = await provider.attachTarget({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId ?? ""
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.attach", {
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId
|
||||
});
|
||||
|
||||
await msg.complete({
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
const runtime = await db
|
||||
.select({ switchTarget: handoffRuntime.activeSwitchTarget })
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" });
|
||||
}
|
||||
|
||||
export async function handlePushActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: msg.body?.reason ?? null,
|
||||
historyKind: "handoff.push"
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleSimpleCommandActivity(
|
||||
loopCtx: any,
|
||||
msg: any,
|
||||
statusMessage: string,
|
||||
historyKind: string
|
||||
): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage, updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "archive_stop_status_sync", "stopping status sync");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
|
||||
if (record.activeSandboxId && record.activeSessionId) {
|
||||
try {
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.handoffId,
|
||||
record.activeSandboxId,
|
||||
record.activeSessionId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
providerId: record.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
intervalMs: 2_000
|
||||
}
|
||||
);
|
||||
await withTimeout(sync.stop(), 15_000, "handoff status sync stop");
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.commands", "failed to stop status sync during archive", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const workspaceId = loopCtx.state.workspaceId;
|
||||
const repoId = loopCtx.state.repoId;
|
||||
const handoffId = loopCtx.state.handoffId;
|
||||
const sandboxId = record.activeSandboxId;
|
||||
|
||||
// Do not block archive finalization on provider stop. Some provider stop calls can
|
||||
// run longer than the synchronous archive UX budget.
|
||||
void withTimeout(
|
||||
provider.releaseSandbox({
|
||||
workspaceId,
|
||||
sandboxId
|
||||
}),
|
||||
45_000,
|
||||
"provider releaseSandbox"
|
||||
).catch((error) => {
|
||||
logActorWarning("handoff.commands", "failed to release sandbox during archive", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
handoffId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
await setHandoffState(loopCtx, "archive_finalize", "finalizing archive");
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status: "archived", updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.archive", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
await provider.destroySandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId
|
||||
});
|
||||
}
|
||||
|
||||
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "kill_finalize", "finalizing kill");
|
||||
const db = loopCtx.db;
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status: "killed", updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage: "killed", updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleGetActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await msg.complete(await getCurrentRecord(loopCtx));
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { getCurrentRecord } from "./common.js";
|
||||
import {
|
||||
initAssertNameActivity,
|
||||
initBootstrapDbActivity,
|
||||
initCompleteActivity,
|
||||
initCreateSandboxActivity,
|
||||
initCreateSessionActivity,
|
||||
initEnsureAgentActivity,
|
||||
initEnsureNameActivity,
|
||||
initFailedActivity,
|
||||
initStartSandboxInstanceActivity,
|
||||
initStartStatusSyncActivity,
|
||||
initWriteDbActivity
|
||||
} from "./init.js";
|
||||
import {
|
||||
handleArchiveActivity,
|
||||
handleAttachActivity,
|
||||
handleGetActivity,
|
||||
handlePushActivity,
|
||||
handleSimpleCommandActivity,
|
||||
handleSwitchActivity,
|
||||
killDestroySandboxActivity,
|
||||
killWriteDbActivity
|
||||
} from "./commands.js";
|
||||
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
|
||||
import { HANDOFF_QUEUE_NAMES } from "./queue.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchHandoff,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
syncWorkbenchSessionStatus,
|
||||
updateWorkbenchDraft,
|
||||
} from "../workbench.js";
|
||||
|
||||
export { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName } from "./queue.js";
|
||||
|
||||
type HandoffQueueName = (typeof HANDOFF_QUEUE_NAMES)[number];
|
||||
|
||||
type WorkflowHandler = (loopCtx: any, msg: { name: HandoffQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
|
||||
|
||||
const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
||||
"handoff.command.initialize": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
|
||||
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-enqueue-provision", "step");
|
||||
await loopCtx.removed("init-dispatch-provision-v2", "step");
|
||||
const currentRecord = await loopCtx.step(
|
||||
"init-read-current-record",
|
||||
async () => getCurrentRecord(loopCtx)
|
||||
);
|
||||
|
||||
try {
|
||||
await msg.complete(currentRecord);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.workflow", "initialize completion failed", {
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"handoff.command.provision": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
await loopCtx.removed("init-failed", "step");
|
||||
try {
|
||||
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
|
||||
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
|
||||
|
||||
const sandbox = await loopCtx.step({
|
||||
name: "init-create-sandbox",
|
||||
timeout: 180_000,
|
||||
run: async () => initCreateSandboxActivity(loopCtx, body),
|
||||
});
|
||||
const agent = await loopCtx.step({
|
||||
name: "init-ensure-agent",
|
||||
timeout: 180_000,
|
||||
run: async () => initEnsureAgentActivity(loopCtx, body, sandbox),
|
||||
});
|
||||
const sandboxInstanceReady = await loopCtx.step({
|
||||
name: "init-start-sandbox-instance",
|
||||
timeout: 60_000,
|
||||
run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent),
|
||||
});
|
||||
const session = await loopCtx.step({
|
||||
name: "init-create-session",
|
||||
timeout: 180_000,
|
||||
run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady),
|
||||
});
|
||||
|
||||
await loopCtx.step(
|
||||
"init-write-db",
|
||||
async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady)
|
||||
);
|
||||
await loopCtx.step("init-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session));
|
||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error));
|
||||
await msg.complete({ ok: false });
|
||||
}
|
||||
},
|
||||
|
||||
"handoff.command.attach": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.switch": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.push": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.sync": async (loopCtx, msg) => {
|
||||
await loopCtx.step(
|
||||
"handle-sync",
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")
|
||||
);
|
||||
},
|
||||
|
||||
"handoff.command.merge": async (loopCtx, msg) => {
|
||||
await loopCtx.step(
|
||||
"handle-merge",
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")
|
||||
);
|
||||
},
|
||||
|
||||
"handoff.command.archive": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.kill": async (loopCtx, msg) => {
|
||||
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
|
||||
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.get": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.workbench.mark_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.rename_handoff": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-handoff", async () => renameWorkbenchHandoff(loopCtx, msg.body.value));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.rename_branch": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-rename-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => renameWorkbenchBranch(loopCtx, msg.body.value),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.create_session": async (loopCtx, msg) => {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||
});
|
||||
await msg.complete(created);
|
||||
},
|
||||
|
||||
"handoff.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-session", async () =>
|
||||
renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.set_session_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-set-session-unread", async () =>
|
||||
setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.update_draft": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-update-draft", async () =>
|
||||
updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.change_model": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-change-model", async () =>
|
||||
changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-stop-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.sync_session_status": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-sync-session-status", async () =>
|
||||
syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.close_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-close-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.publish_pr": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-publish-pr",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => publishWorkbenchPr(loopCtx),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.revert_file": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-revert-file",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => revertWorkbenchFile(loopCtx, msg.body.path),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.status_sync.result": async (loopCtx, msg) => {
|
||||
const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body));
|
||||
|
||||
if (transitionedToIdle) {
|
||||
const { config } = getActorRuntimeContext();
|
||||
if (config.auto_submit) {
|
||||
await loopCtx.step("idle-submit-pr", async () => idleSubmitPrActivity(loopCtx));
|
||||
}
|
||||
await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function runHandoffWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("handoff-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-command", {
|
||||
names: [...HANDOFF_QUEUE_NAMES],
|
||||
completable: true
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
const handler = commandHandlers[msg.name as HandoffQueueName];
|
||||
if (handler) {
|
||||
await handler(loopCtx, msg);
|
||||
}
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,643 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import {
|
||||
getOrCreateHandoffStatusSync,
|
||||
getOrCreateHistory,
|
||||
getOrCreateProject,
|
||||
getOrCreateSandboxInstance,
|
||||
getSandboxInstance,
|
||||
selfHandoff
|
||||
} from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import {
|
||||
HANDOFF_ROW_ID,
|
||||
appendHistory,
|
||||
buildAgentPrompt,
|
||||
collectErrorMessages,
|
||||
resolveErrorDetail,
|
||||
setHandoffState
|
||||
} from "./common.js";
|
||||
import { handoffWorkflowQueueName } from "./queue.js";
|
||||
|
||||
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
|
||||
|
||||
function getInitCreateSandboxActivityTimeoutMs(): number {
|
||||
const raw = process.env.HF_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
if (!raw) {
|
||||
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function debugInit(loopCtx: any, message: string, context?: Record<string, unknown>): void {
|
||||
loopCtx.log.debug({
|
||||
msg: message,
|
||||
scope: "handoff.init",
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
...(context ?? {})
|
||||
});
|
||||
}
|
||||
|
||||
async function withActivityTimeout<T>(
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
run(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(handoffTable)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
const detail = resolveErrorMessage(error);
|
||||
throw new Error(`handoff init bootstrap db failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_enqueue_provision", "provision queued");
|
||||
const self = selfHandoff(loopCtx);
|
||||
void self
|
||||
.send(handoffWorkflowQueueName("handoff.command.provision"), body, {
|
||||
wait: false,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logActorWarning("handoff.init", "background provision command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_ensure_name", "determining title and branch");
|
||||
const existing = await loopCtx.db
|
||||
.select({
|
||||
branchName: handoffTable.branchName,
|
||||
title: handoffTable.title
|
||||
})
|
||||
.from(handoffTable)
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (existing?.branchName && existing?.title) {
|
||||
loopCtx.state.branchName = existing.branchName;
|
||||
loopCtx.state.title = existing.title;
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.init", "fetch before naming failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map(
|
||||
(branch: any) => branch.branchName
|
||||
);
|
||||
|
||||
const project = await getOrCreateProject(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.repoRemote
|
||||
);
|
||||
const reservedBranches = await project.listReservedBranches({});
|
||||
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
task: loopCtx.state.task,
|
||||
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
|
||||
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
|
||||
localBranches: remoteBranches,
|
||||
handoffBranches: reservedBranches
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
branchName: resolved.branchName,
|
||||
title: resolved.title,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
loopCtx.state.branchName = resolved.branchName;
|
||||
loopCtx.state.title = resolved.title;
|
||||
loopCtx.state.explicitTitle = null;
|
||||
loopCtx.state.explicitBranchName = null;
|
||||
|
||||
await loopCtx.db
|
||||
.update(handoffRuntime)
|
||||
.set({
|
||||
statusMessage: "provisioning",
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await project.registerHandoffBranch({
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: resolved.branchName
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.named", {
|
||||
title: resolved.title,
|
||||
branchName: resolved.branchName
|
||||
});
|
||||
}
|
||||
|
||||
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_assert_name", "validating naming");
|
||||
if (!loopCtx.state.branchName) {
|
||||
throw new Error("handoff branchName is not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_create_sandbox", "creating sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
const timeoutMs = getInitCreateSandboxActivityTimeoutMs();
|
||||
const startedAt = Date.now();
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox started", {
|
||||
providerId,
|
||||
timeoutMs,
|
||||
supportsSessionReuse: provider.capabilities().supportsSessionReuse
|
||||
});
|
||||
|
||||
if (provider.capabilities().supportsSessionReuse) {
|
||||
const runtime = await loopCtx.db
|
||||
.select({ activeSandboxId: handoffRuntime.activeSandboxId })
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
const existing = await loopCtx.db
|
||||
.select({ sandboxId: handoffSandboxes.sandboxId })
|
||||
.from(handoffSandboxes)
|
||||
.where(eq(handoffSandboxes.providerId, providerId))
|
||||
.orderBy(desc(handoffSandboxes.updatedAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const sandboxId = runtime?.activeSandboxId ?? existing?.sandboxId ?? null;
|
||||
if (sandboxId) {
|
||||
debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId });
|
||||
try {
|
||||
const resumed = await withActivityTimeout(
|
||||
timeoutMs,
|
||||
"resumeSandbox",
|
||||
async () => provider.resumeSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId
|
||||
})
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox resume succeeded", {
|
||||
sandboxId: resumed.sandboxId,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
return resumed;
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.init", "resume sandbox failed; creating a new sandbox", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", {
|
||||
branchName: loopCtx.state.branchName
|
||||
});
|
||||
|
||||
try {
|
||||
const sandbox = await withActivityTimeout(
|
||||
timeoutMs,
|
||||
"createSandbox",
|
||||
async () => provider.createSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
repoRemote: loopCtx.state.repoRemote,
|
||||
branchName: loopCtx.state.branchName,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
debug: (message, context) => debugInit(loopCtx, message, context)
|
||||
})
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox create succeeded", {
|
||||
sandboxId: sandbox.sandboxId,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
return sandbox;
|
||||
} catch (error) {
|
||||
debugInit(loopCtx, "init_create_sandbox failed", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
return await provider.ensureSandboxAgent({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: sandbox.sandboxId
|
||||
});
|
||||
}
|
||||
|
||||
export async function initStartSandboxInstanceActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
agent: any
|
||||
): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
try {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
providerId,
|
||||
sandbox.sandboxId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId
|
||||
}
|
||||
);
|
||||
|
||||
await sandboxInstance.ensure({
|
||||
metadata: sandbox.metadata,
|
||||
status: "ready",
|
||||
agentEndpoint: agent.endpoint,
|
||||
agentToken: agent.token
|
||||
});
|
||||
|
||||
const actorId = typeof (sandboxInstance as any).resolve === "function"
|
||||
? await (sandboxInstance as any).resolve()
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
actorId: typeof actorId === "string" ? actorId : null
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `sandbox-instance ensure failed: ${detail}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSessionActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
sandboxInstanceReady: any
|
||||
): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_create_session", "creating agent session");
|
||||
if (!sandboxInstanceReady.ok) {
|
||||
return {
|
||||
id: null,
|
||||
status: "error",
|
||||
error: sandboxInstanceReady.error ?? "sandbox instance is not ready"
|
||||
} as const;
|
||||
}
|
||||
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
|
||||
|
||||
const cwd =
|
||||
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
|
||||
? ((sandbox.metadata as any).cwd as string)
|
||||
: undefined;
|
||||
|
||||
return await sandboxInstance.createSession({
|
||||
prompt: buildAgentPrompt(loopCtx.state.task),
|
||||
cwd,
|
||||
agent: (loopCtx.state.agentType ?? config.default_agent) as any
|
||||
});
|
||||
}
|
||||
|
||||
export async function initWriteDbActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
session: any,
|
||||
sandboxInstanceReady?: { actorId?: string | null }
|
||||
): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
const activeSessionId = sessionHealthy ? sessionId : null;
|
||||
const statusMessage =
|
||||
sessionHealthy
|
||||
? "session created"
|
||||
: session?.status === "error"
|
||||
? (session.error ?? "session create failed")
|
||||
: "session unavailable";
|
||||
|
||||
const activeCwd =
|
||||
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
|
||||
? ((sandbox.metadata as any).cwd as string)
|
||||
: null;
|
||||
const sandboxActorId =
|
||||
typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0
|
||||
? sandboxInstanceReady.actorId
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
providerId,
|
||||
status: sessionHealthy ? "running" : "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffSandboxes)
|
||||
.values({
|
||||
sandboxId: sandbox.sandboxId,
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initStartStatusSyncActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
session: any
|
||||
): Promise<void> {
|
||||
const sessionId = session?.id ?? null;
|
||||
if (!sessionId || session?.status === "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
await setHandoffState(loopCtx, "init_start_status_sync", "starting session status sync");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.handoffId,
|
||||
sandbox.sandboxId,
|
||||
sessionId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
sessionId,
|
||||
intervalMs: 2_000
|
||||
}
|
||||
);
|
||||
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
}
|
||||
|
||||
export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
if (sessionHealthy) {
|
||||
await setHandoffState(loopCtx, "init_complete", "handoff initialized");
|
||||
|
||||
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
|
||||
await history.append({
|
||||
kind: "handoff.initialized",
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId }
|
||||
});
|
||||
|
||||
loopCtx.state.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const detail =
|
||||
session?.status === "error"
|
||||
? (session.error ?? "session create failed")
|
||||
: "session unavailable";
|
||||
await setHandoffState(loopCtx, "error", detail);
|
||||
await appendHistory(loopCtx, "handoff.error", {
|
||||
detail,
|
||||
messages: [detail]
|
||||
});
|
||||
loopCtx.state.initialized = false;
|
||||
}
|
||||
|
||||
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
|
||||
const now = Date.now();
|
||||
const detail = resolveErrorDetail(error);
|
||||
const messages = collectErrorMessages(error);
|
||||
const db = loopCtx.db;
|
||||
const { config, providers } = getActorRuntimeContext();
|
||||
const providerId = loopCtx.state.providerId ?? providers.defaultProviderId();
|
||||
|
||||
await db
|
||||
.insert(handoffTable)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.error", {
|
||||
detail,
|
||||
messages
|
||||
});
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
|
||||
|
||||
export interface PushActiveBranchOptions {
|
||||
reason?: string | null;
|
||||
historyKind?: string;
|
||||
}
|
||||
|
||||
export async function pushActiveBranchActivity(
|
||||
loopCtx: any,
|
||||
options: PushActiveBranchOptions = {}
|
||||
): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const branchName = loopCtx.state.branchName ?? record.branchName;
|
||||
|
||||
if (!activeSandboxId) {
|
||||
throw new Error("cannot push: no active sandbox");
|
||||
}
|
||||
if (!branchName) {
|
||||
throw new Error("cannot push: handoff branch is not set");
|
||||
}
|
||||
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
|
||||
const providerId = activeSandbox?.providerId ?? record.providerId;
|
||||
const cwd = activeSandbox?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot push: active sandbox cwd is not set");
|
||||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(providerId);
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(handoffSandboxes)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(handoffSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`cd ${JSON.stringify(cwd)}`,
|
||||
"git rev-parse --verify HEAD >/dev/null",
|
||||
"git config credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'",
|
||||
`git push -u origin ${JSON.stringify(branchName)}`
|
||||
].join("; ");
|
||||
|
||||
const result = await provider.executeCommand({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: activeSandboxId,
|
||||
command: ["bash", "-lc", JSON.stringify(script)].join(" "),
|
||||
label: `git push ${branchName}`
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`git push failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await loopCtx.db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(handoffSandboxes)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(handoffSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, options.historyKind ?? "handoff.push", {
|
||||
reason: options.reason ?? null,
|
||||
branchName,
|
||||
sandboxId: activeSandboxId
|
||||
});
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
export const HANDOFF_QUEUE_NAMES = [
|
||||
"handoff.command.initialize",
|
||||
"handoff.command.provision",
|
||||
"handoff.command.attach",
|
||||
"handoff.command.switch",
|
||||
"handoff.command.push",
|
||||
"handoff.command.sync",
|
||||
"handoff.command.merge",
|
||||
"handoff.command.archive",
|
||||
"handoff.command.kill",
|
||||
"handoff.command.get",
|
||||
"handoff.command.workbench.mark_unread",
|
||||
"handoff.command.workbench.rename_handoff",
|
||||
"handoff.command.workbench.rename_branch",
|
||||
"handoff.command.workbench.create_session",
|
||||
"handoff.command.workbench.rename_session",
|
||||
"handoff.command.workbench.set_session_unread",
|
||||
"handoff.command.workbench.update_draft",
|
||||
"handoff.command.workbench.change_model",
|
||||
"handoff.command.workbench.send_message",
|
||||
"handoff.command.workbench.stop_session",
|
||||
"handoff.command.workbench.sync_session_status",
|
||||
"handoff.command.workbench.close_session",
|
||||
"handoff.command.workbench.publish_pr",
|
||||
"handoff.command.workbench.revert_file",
|
||||
"handoff.status_sync.result"
|
||||
] as const;
|
||||
|
||||
export function handoffWorkflowQueueName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
function mapSessionStatus(status: "running" | "idle" | "error") {
|
||||
if (status === "idle") return "idle";
|
||||
if (status === "error") return "error";
|
||||
return "running";
|
||||
}
|
||||
|
||||
export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boolean> {
|
||||
const newStatus = mapSessionStatus(body.status);
|
||||
const wasIdle = loopCtx.state.previousStatus === "idle";
|
||||
const didTransition = newStatus === "idle" && !wasIdle;
|
||||
const isDuplicateStatus = loopCtx.state.previousStatus === newStatus;
|
||||
|
||||
if (isDuplicateStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
const runtime = await db
|
||||
.select({
|
||||
activeSandboxId: handoffRuntime.activeSandboxId,
|
||||
activeSessionId: handoffRuntime.activeSessionId
|
||||
})
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
const isActive =
|
||||
runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
|
||||
|
||||
if (isActive) {
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status: newStatus, updatedAt: body.at })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(handoffSandboxes)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(handoffSandboxes.sandboxId, body.sandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.status", {
|
||||
status: body.status,
|
||||
sessionId: body.sessionId,
|
||||
sandboxId: body.sandboxId
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
loopCtx.state.previousStatus = newStatus;
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
if (loopCtx.state.branchName) {
|
||||
driver.tmux.setWindowStatus(loopCtx.state.branchName, newStatus);
|
||||
}
|
||||
return didTransition;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const db = loopCtx.db;
|
||||
|
||||
const self = await db
|
||||
.select({ prSubmitted: handoffTable.prSubmitted })
|
||||
.from(handoffTable)
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (self && self.prSubmitted) return;
|
||||
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.status-sync", "fetch before PR submit failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
|
||||
if (!loopCtx.state.branchName || !loopCtx.state.title) {
|
||||
throw new Error("cannot submit PR before handoff has a branch and title");
|
||||
}
|
||||
|
||||
try {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: "auto_submit_idle",
|
||||
historyKind: "handoff.push.auto"
|
||||
});
|
||||
|
||||
const pr = await driver.github.createPr(
|
||||
loopCtx.state.repoLocalPath,
|
||||
loopCtx.state.branchName,
|
||||
loopCtx.state.title
|
||||
);
|
||||
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ prSubmitted: 1, updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.step", {
|
||||
step: "pr_submit",
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.pr_created", {
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = resolveErrorDetail(error);
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({
|
||||
statusMessage: `pr submit failed: ${detail}`,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.pr_create_failed", {
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
error: detail
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function idleNotifyActivity(loopCtx: any): Promise<void> {
|
||||
const { notifications } = getActorRuntimeContext();
|
||||
if (notifications && loopCtx.state.branchName) {
|
||||
await notifications.agentIdle(loopCtx.state.branchName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const historyDb = actorSqliteDb({
|
||||
actorName: "history",
|
||||
schema,
|
||||
migrations,
|
||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
||||
});
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { setup } from "rivetkit";
|
||||
import { handoffStatusSync } from "./handoff-status-sync/index.js";
|
||||
import { handoff } from "./handoff/index.js";
|
||||
import { history } from "./history/index.js";
|
||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
||||
import { project } from "./project/index.js";
|
||||
import { sandboxInstance } from "./sandbox-instance/index.js";
|
||||
import { workspace } from "./workspace/index.js";
|
||||
|
||||
function resolveManagerPort(): number {
|
||||
const raw = process.env.HF_RIVET_MANAGER_PORT ?? process.env.RIVETKIT_MANAGER_PORT;
|
||||
if (!raw) {
|
||||
return 7750;
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
throw new Error(`Invalid HF_RIVET_MANAGER_PORT/RIVETKIT_MANAGER_PORT: ${raw}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveManagerHost(): string {
|
||||
const raw = process.env.HF_RIVET_MANAGER_HOST ?? process.env.RIVETKIT_MANAGER_HOST;
|
||||
return raw && raw.trim().length > 0 ? raw.trim() : "0.0.0.0";
|
||||
}
|
||||
|
||||
export const registry = setup({
|
||||
use: {
|
||||
workspace,
|
||||
project,
|
||||
handoff,
|
||||
sandboxInstance,
|
||||
history,
|
||||
projectPrSync,
|
||||
projectBranchSync,
|
||||
handoffStatusSync
|
||||
},
|
||||
managerPort: resolveManagerPort(),
|
||||
managerHost: resolveManagerHost()
|
||||
});
|
||||
|
||||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./handoff-status-sync/index.js";
|
||||
export * from "./handoff/index.js";
|
||||
export * from "./history/index.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./project-branch-sync/index.js";
|
||||
export * from "./project-pr-sync/index.js";
|
||||
export * from "./project/index.js";
|
||||
export * from "./sandbox-instance/index.js";
|
||||
export * from "./workspace/index.js";
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
export type ActorKey = string[];
|
||||
|
||||
export function workspaceKey(workspaceId: string): ActorKey {
|
||||
return ["ws", workspaceId];
|
||||
}
|
||||
|
||||
export function projectKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId];
|
||||
}
|
||||
|
||||
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
|
||||
}
|
||||
|
||||
export function sandboxInstanceKey(
|
||||
workspaceId: string,
|
||||
providerId: string,
|
||||
sandboxId: string
|
||||
): ActorKey {
|
||||
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "history"];
|
||||
}
|
||||
|
||||
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "pr-sync"];
|
||||
}
|
||||
|
||||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||
}
|
||||
|
||||
export function handoffStatusSyncKey(
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
sandboxId: string,
|
||||
sessionId: string
|
||||
): ActorKey {
|
||||
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
|
||||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type { GitDriver } from "../../driver.js";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getProject, selfProjectBranchSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
import { parentLookupFromStack } from "../project/stack-model.js";
|
||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||
|
||||
export interface ProjectBranchSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface SetIntervalCommand {
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface EnrichedBranchSnapshot {
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
parentBranch: string | null;
|
||||
trackedInStack: boolean;
|
||||
diffStat: string | null;
|
||||
hasUnpushed: boolean;
|
||||
conflictsWithMain: boolean;
|
||||
}
|
||||
|
||||
interface ProjectBranchSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "project.branch_sync.control.start",
|
||||
stop: "project.branch_sync.control.stop",
|
||||
setInterval: "project.branch_sync.control.set_interval",
|
||||
force: "project.branch_sync.control.force"
|
||||
} as const;
|
||||
|
||||
async function enrichBranches(
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
git: GitDriver
|
||||
): Promise<EnrichedBranchSnapshot[]> {
|
||||
return await withRepoGitLock(repoPath, async () => {
|
||||
await git.fetch(repoPath);
|
||||
const branches = await git.listRemoteBranches(repoPath);
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const stackEntries = await driver.stack.listStack(repoPath).catch(() => []);
|
||||
const parentByBranch = parentLookupFromStack(stackEntries);
|
||||
const enriched: EnrichedBranchSnapshot[] = [];
|
||||
|
||||
const baseRef = await git.remoteDefaultBaseRef(repoPath);
|
||||
const baseSha = await git.revParse(repoPath, baseRef).catch(() => "");
|
||||
|
||||
for (const branch of branches) {
|
||||
let branchDiffStat: string | null = null;
|
||||
let branchHasUnpushed = false;
|
||||
let branchConflicts = false;
|
||||
|
||||
try {
|
||||
branchDiffStat = await git.diffStatForBranch(repoPath, branch.branchName);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "diffStatForBranch failed", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
branchDiffStat = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const headSha = await git.revParse(repoPath, `origin/${branch.branchName}`);
|
||||
branchHasUnpushed = Boolean(baseSha && headSha && headSha !== baseSha);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "revParse failed", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
branchHasUnpushed = false;
|
||||
}
|
||||
|
||||
try {
|
||||
branchConflicts = await git.conflictsWithMain(repoPath, branch.branchName);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "conflictsWithMain failed", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
branchConflicts = false;
|
||||
}
|
||||
|
||||
enriched.push({
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
parentBranch: parentByBranch.get(branch.branchName) ?? null,
|
||||
trackedInStack: parentByBranch.has(branch.branchName),
|
||||
diffStat: branchDiffStat,
|
||||
hasUnpushed: branchHasUnpushed,
|
||||
conflictsWithMain: branchConflicts
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
|
||||
async function pollBranches(c: { state: ProjectBranchSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const enrichedItems = await enrichBranches(c.state.workspaceId, c.state.repoId, c.state.repoPath, driver.git);
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyBranchSyncResult({ items: enrichedItems, at: Date.now() });
|
||||
}
|
||||
|
||||
export const projectBranchSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
[CONTROL.setInterval]: queue(),
|
||||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
},
|
||||
createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, {
|
||||
loopName: "project-branch-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollBranches(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getProject, selfProjectPrSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
|
||||
export interface ProjectPrSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface SetIntervalCommand {
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface ProjectPrSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "project.pr_sync.control.start",
|
||||
stop: "project.pr_sync.control.stop",
|
||||
setInterval: "project.pr_sync.control.set_interval",
|
||||
force: "project.pr_sync.control.force"
|
||||
} as const;
|
||||
|
||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const items = await driver.github.listPullRequests(c.state.repoPath);
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyPrSyncResult({ items, at: Date.now() });
|
||||
}
|
||||
|
||||
export const projectPrSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
[CONTROL.setInterval]: queue(),
|
||||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
},
|
||||
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
|
||||
loopName: "project-pr-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollPrs(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("project-pr-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue