Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

1
foundry/AGENTS.md Symbolic link
View file

@ -0,0 +1 @@
CLAUDE.md

234
foundry/CLAUDE.md Normal file
View file

@ -0,0 +1,234 @@
# 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 `@sandbox-agent/foundry-cli`.
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
## Common Commands
- Foundry is the canonical name for this product tree. Do not introduce or preserve legacy pre-Foundry naming in code, docs, commands, or runtime paths.
- Install deps: `pnpm install`
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
- Start the full dev stack: `just foundry-dev`
- Start the local production-build preview stack: `just foundry-preview`
- Start only the backend locally: `just foundry-backend-start`
- Start only the frontend locally: `pnpm --filter @sandbox-agent/foundry-frontend dev`
- Start the frontend against the mock workbench client: `FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev`
- Stop the compose dev stack: `just foundry-dev-down`
- Tail compose logs: `just foundry-dev-logs`
- Stop the preview stack: `just foundry-preview-down`
- Tail preview logs: `just foundry-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.
- If Foundry uses a shared component from `@sandbox-agent/react`, make changes in `sdks/react` instead of copying or forking that component into Foundry.
- When changing shared React components in `sdks/react` for Foundry, verify they still work in the Sandbox Agent Inspector before finishing.
- 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`/`taskId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
- Example: the `task` actor instance already represents `(workspaceId, repoId, taskId)`, so its SQLite tables should not need those columns for primary keys.
3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`).
4. The default dependency source for RivetKit is the published `rivetkit` package so workspace installs and CI remain self-contained.
5. When working on coordinated RivetKit changes, you may temporarily relink to a local checkout instead of the published package.
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/task/rivet-checkout`
- Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit`
- Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the RivetKit workspace when using the local checkout.
6. Before using a local checkout, build RivetKit in the rivet repo:
```bash
cd ../rivet-checkout/rivetkit-typescript
pnpm install
pnpm build -F rivetkit
```
## 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=task`
- 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.
- Task 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=task" \
| jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/task/\($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 `@sandbox-agent/foundry-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`, `task`, `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/foundry/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 `{ taskId }`, `{ 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/foundry/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.

64
foundry/CONTRIBUTING.md Normal file
View file

@ -0,0 +1,64 @@
# Contributing
## Development Setup
1. Clone:
```bash
git clone https://github.com/rivet-dev/sandbox-agent.git
cd sandbox-agent/foundry
```
2. Install dependencies:
```bash
pnpm install
```
3. Build all packages:
```bash
pnpm -w build
```
## Package Layout
- `packages/shared`: contracts/schemas
- `packages/backend`: RivetKit actors + DB + providers + integrations
- `packages/cli`: `hf` and `hf tui` (OpenTUI)
## Local RivetKit Dependency
Build local RivetKit before backend changes that depend on Rivet internals:
```bash
cd ../rivet
pnpm build -F rivetkit
cd /path/to/sandbox-agent/foundry
just sync-rivetkit
```
## Validation
Run before opening a PR:
```bash
pnpm -w typecheck
pnpm -w build
pnpm -w test
```
## Dev Backend (Docker Compose)
Start the dev backend (hot reload via `bun --watch`) and Vite frontend via Docker Compose:
```bash
just foundry-dev
```
Stop it:
```bash
just foundry-dev-down
```

53
foundry/Dockerfile Normal file
View file

@ -0,0 +1,53 @@
# 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 @sandbox-agent/foundry-backend...
FROM base AS build
COPY --from=deps /pnpm/store /pnpm/store
COPY . .
RUN pnpm install --frozen-lockfile --prefer-offline --filter @sandbox-agent/foundry-backend...
RUN pnpm --filter @sandbox-agent/foundry-shared build
RUN pnpm --filter @sandbox-agent/foundry-backend build
RUN pnpm --filter @sandbox-agent/foundry-backend deploy --prod --legacy /out
FROM oven/bun:1.2 AS runtime
ENV NODE_ENV=production
ENV HOME=/home/task
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 task \
&& adduser --system --uid 1001 --home /home/task --ingroup task task \
&& mkdir -p /home/task \
&& chown -R task:task /home/task /app
COPY --from=build /out ./
USER task
EXPOSE 7741
CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"]

24
foundry/README.md Normal file
View file

@ -0,0 +1,24 @@
# Foundry
TypeScript workspace task system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI.
**Documentation**: see `../docs/` in the repository root
## Quick Install
```bash
curl -fsSL https://bun.sh/install | bash
pnpm install
pnpm -w build
```
## Project Goals
- **Simple**: There's one screen. It has everything you need. You can use it blindfolded.
- **Fast**: No waiting around.
- **Collaborative**: Built for fast moving teams that need code reviewed & shipped fast.
- **Pluggable**: Works for small side projects to enterprise teams.
## License
MIT

90
foundry/compose.dev.yaml Normal file
View file

@ -0,0 +1,90 @@
name: foundry
services:
backend:
build:
context: ..
dockerfile: foundry/docker/backend.dev.Dockerfile
image: foundry-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/foundry/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 Foundry packages to /task/rivet-checkout in-container.
- "../../../task/rivet-checkout:/task/rivet-checkout:ro"
# Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev.
- "${HOME}/.codex:/root/.codex"
# Keep backend dependency installs Linux-native instead of using host node_modules.
- "foundry_backend_root_node_modules:/app/node_modules"
- "foundry_backend_backend_node_modules:/app/foundry/packages/backend/node_modules"
- "foundry_backend_shared_node_modules:/app/foundry/packages/shared/node_modules"
- "foundry_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules"
- "foundry_backend_typescript_node_modules:/app/sdks/typescript/node_modules"
- "foundry_backend_pnpm_store:/root/.local/share/pnpm/store"
# Persist backend-managed local git clones across container restarts.
- "foundry_git_repos:/root/.local/share/foundry/repos"
# Persist RivetKit local storage across container restarts.
- "foundry_rivetkit_storage:/root/.local/share/foundry/rivetkit"
frontend:
build:
context: ..
dockerfile: foundry/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 .foundry/ persist on the host even if we change source mounts later.
- "./.foundry:/app/foundry/.foundry"
- "../../../task/rivet-checkout:/task/rivet-checkout:ro"
# Use Linux-native workspace dependencies inside the container instead of host node_modules.
- "foundry_node_modules:/app/node_modules"
- "foundry_client_node_modules:/app/foundry/packages/client/node_modules"
- "foundry_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules"
- "foundry_frontend_node_modules:/app/foundry/packages/frontend/node_modules"
- "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules"
- "foundry_pnpm_store:/tmp/.local/share/pnpm/store"
volumes:
foundry_backend_root_node_modules: {}
foundry_backend_backend_node_modules: {}
foundry_backend_shared_node_modules: {}
foundry_backend_persist_rivet_node_modules: {}
foundry_backend_typescript_node_modules: {}
foundry_backend_pnpm_store: {}
foundry_git_repos: {}
foundry_rivetkit_storage: {}
foundry_node_modules: {}
foundry_client_node_modules: {}
foundry_frontend_errors_node_modules: {}
foundry_frontend_node_modules: {}
foundry_shared_node_modules: {}
foundry_pnpm_store: {}

View file

@ -0,0 +1,44 @@
name: foundry-preview
services:
backend:
build:
context: ..
dockerfile: foundry/docker/backend.preview.Dockerfile
image: foundry-backend-preview
environment:
HF_BACKEND_HOST: "0.0.0.0"
HF_BACKEND_PORT: "7841"
HF_RIVET_MANAGER_PORT: "8850"
RIVETKIT_STORAGE_PATH: "/root/.local/share/foundry/rivetkit"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}"
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:
- "7841:7841"
- "8850:8850"
volumes:
- "${HOME}/.codex:/root/.codex"
- "foundry_preview_git_repos:/root/.local/share/foundry/repos"
- "foundry_preview_rivetkit_storage:/root/.local/share/foundry/rivetkit"
frontend:
build:
context: ..
dockerfile: foundry/docker/frontend.preview.Dockerfile
image: foundry-frontend-preview
depends_on:
- backend
ports:
- "4273:4273"
volumes:
foundry_preview_git_repos: {}
foundry_preview_rivetkit_storage: {}

View file

@ -0,0 +1,42 @@
# 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 @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]

View file

@ -0,0 +1,49 @@
# 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 \
&& npm install -g pnpm@10.28.2 \
&& rm -rf /var/lib/apt/lists/*
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 /workspace/quebec
COPY quebec /workspace/quebec
COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @sandbox-agent/foundry-shared build
RUN pnpm --filter @sandbox-agent/foundry-client build
RUN pnpm --filter @sandbox-agent/foundry-backend build
CMD ["bash", "-lc", "git config --global --add safe.directory /workspace/quebec >/dev/null 2>&1 || true; exec bun packages/backend/dist/index.js start --host 0.0.0.0 --port 7841"]

View file

@ -0,0 +1,11 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
# Install pnpm into the image so we can run as a non-root user at runtime.
# Using npm here avoids Corepack's first-run download behavior.
RUN npm install -g pnpm@10.28.2
WORKDIR /app
CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-frontend... && cd foundry/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"]

View file

@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS build
RUN npm install -g pnpm@10.28.2
WORKDIR /workspace/quebec
COPY quebec /workspace/quebec
COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @sandbox-agent/foundry-shared build
RUN pnpm --filter @sandbox-agent/foundry-client build
RUN pnpm --filter @sandbox-agent/foundry-frontend-errors build
RUN pnpm --filter @sandbox-agent/foundry-frontend build
FROM nginx:1.27-alpine
COPY quebec/docker/nginx.preview.conf /etc/nginx/conf.d/default.conf
COPY --from=build /workspace/quebec/packages/frontend/dist /usr/share/nginx/html
EXPOSE 4273

View file

@ -0,0 +1,31 @@
server {
listen 4273;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/rivet/ {
proxy_pass http://backend:7841/api/rivet/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location = /api/rivet {
proxy_pass http://backend:7841/api/rivet;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -0,0 +1 @@
wb-mmilw7yh

View file

@ -0,0 +1 @@
wb-mmilzdwf

12
foundry/foundry-cloud.md Normal file
View file

@ -0,0 +1,12 @@
# Foundry Cloud
## Mock Server
If you are running the mock server with Beat instead of `docker compose`, use a team accession for the process so it does not terminate when your message is finished.
A detached `tmux` session is acceptable for this. Example:
```bash
tmux new-session -d -s mock-ui-4180 \
'cd /Users/nathan/conductor/workspaces/sandbox-agent/provo && FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend exec vite --host localhost --port 4180'
```

82
foundry/memory/roadmap.md Normal file
View file

@ -0,0 +1,82 @@
## workflow
### terminal
1. hf create "do something"
2. notifies via openclaw
### claude code/opencode
1. "task this task to do xxxx"
2. ask clarifying questions
3. works in background (attach opencode session with `hf attach` and switch to session with `hf switch`)
4. automatically submits draft pr (if configured)
5. notifies via openclaw (wip)
### openclaw
(similar to claude code)
### mobile
1. open opencode web ui
## todo
- add -a flag to add to create to attach to it
- backend mode
- fix our tests
- update icons.rs to include colors for the icons
## ideas
- reminders (ctrl r)
- notifications
- check for duplicates/simlar prs
- if plan -> searches for exsiting funcionality, creates plan asking clarying questions
- automatically check off of todo list when done
- fix opencode path, cannot find config file
- unread indicato
- add inbox that is the source of truth for this
- show this on hf above everything else
- sync command
- refactor sessions: ~/.claude/plans/sleepy-frolicking-nest.md
- keep switch active after archive
- add an icon if there are merge conflicts
- add `hf -`
- ask -> do research in a codebase
- todo list integrations (linear, github, etc)
- show issues due soon in switch
- search issues from cli
- create issues from cli
- keep tmux window name in sync with the agent status
- move all tools (github, graphite, git) too tools/ folder
- show git tree
- editor plugins
- vs code
- tmux
- zed
- opencode web
- have hf switch periodically refresh on agent status
- add new columns
- model (for the agent)
- todo list & plan management -> with simplenote sync
- sqlite (global)
- list of all global task repos
- heartbeat status to tell openclaw what it needs to send you
- sandbox agent sdk support
- serve command to run server
- multi-repo support (list for all repos)
- pluggable notification system
- cron jobs
- sandbox support
- auto-boot sandboxes for prs
- menubar
- notes integration
## cool details
- automatically uses your opencode theme
- auto symlink target/node_modules/etc
- auto-archives tasks when closed
- shows agent status in the tmux window name

View file

@ -0,0 +1,36 @@
# 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
│ └─ TaskActor(task)
│ ├─ TaskSessionActor(session) × N
│ │ └─ SessionStatusSyncActor(session) × 0..1
│ └─ Task-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.
- `TaskActor` is one branch. Treat `1 task = 1 branch` once branch assignment is finalized.
- `TaskActor` can have many sessions.
- `TaskActor` 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 `TaskActor`; tasks/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.

View file

@ -0,0 +1,35 @@
{
"name": "@sandbox-agent/foundry-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm",
"db:generate": "find src/actors -name drizzle.config.ts -exec pnpm exec drizzle-kit generate --config {} \\; && \"$HOME/.bun/bin/bun\" src/actors/_scripts/generate-actor-migrations.ts",
"typecheck": "tsc --noEmit",
"test": "$HOME/.bun/bin/bun x vitest run",
"start": "bun dist/index.js start"
},
"dependencies": {
"@daytonaio/sdk": "0.141.0",
"@hono/node-server": "^1.19.7",
"@hono/node-ws": "^1.3.0",
"@iarna/toml": "^2.2.5",
"@sandbox-agent/foundry-shared": "workspace:*",
"@sandbox-agent/persist-rivet": "workspace:*",
"drizzle-orm": "^0.44.5",
"hono": "^4.11.9",
"pino": "^10.3.1",
"rivetkit": "2.1.6",
"sandbox-agent": "workspace:*",
"uuid": "^13.0.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/bun": "^1.3.9",
"drizzle-kit": "^0.31.8",
"tsup": "^8.5.0"
}
}

View file

@ -0,0 +1,135 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
type Journal = {
entries?: Array<{
idx: number;
when: number;
tag: string;
breakpoints?: boolean;
version?: string;
}>;
};
function padMigrationKey(idx: number): string {
return `m${String(idx).padStart(4, "0")}`;
}
function escapeTemplateLiteral(value: string): string {
return value.replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
async function fileExists(path: string): Promise<boolean> {
try {
await readFile(path);
return true;
} catch {
return false;
}
}
async function walkDirectories(root: string, onDir: (dir: string) => Promise<void>): Promise<void> {
const entries = await readdir(root, { withFileTypes: true });
await onDir(root);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
continue;
}
await walkDirectories(join(root, entry.name), onDir);
}
}
async function generateOne(drizzleDir: string): Promise<void> {
const metaDir = resolve(drizzleDir, "meta");
const journalPath = resolve(metaDir, "_journal.json");
if (!(await fileExists(journalPath))) {
return;
}
const drizzleEntries = (await readdir(drizzleDir, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
.map((entry) => entry.name)
.sort();
if (drizzleEntries.length === 0) {
return;
}
const journalRaw = await readFile(journalPath, "utf8");
const journal = JSON.parse(journalRaw) as Journal;
const entries = journal.entries ?? [];
const sqlByKey = new Map<string, string>();
for (const entry of entries) {
const file = drizzleEntries[entry.idx];
if (!file) {
throw new Error(`Missing migration SQL file for idx=${entry.idx} in ${drizzleDir}`);
}
const sqlPath = resolve(drizzleDir, file);
const sqlRaw = await readFile(sqlPath, "utf8");
sqlByKey.set(padMigrationKey(entry.idx), sqlRaw);
}
const migrationsObjectLines: string[] = [];
for (const entry of entries) {
const key = padMigrationKey(entry.idx);
const sql = sqlByKey.get(key);
if (!sql) continue;
migrationsObjectLines.push(` ${key}: \`${escapeTemplateLiteral(sql)}\`,`);
}
const banner = `// 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 journalLiteral = JSON.stringify(
{
entries: entries.map((entry) => ({
idx: entry.idx,
when: entry.when,
tag: entry.tag,
breakpoints: Boolean(entry.breakpoints),
})),
},
null,
2,
);
const outPath = resolve(drizzleDir, "..", "migrations.ts");
const content = `${banner}
const journal = ${journalLiteral} as const;
export default {
journal,
migrations: {
${migrationsObjectLines.join("\n")}
} as const
};
`;
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, content, "utf8");
// drizzle-kit generates a JS helper file by default; delete to keep TS-only sources.
await rm(resolve(drizzleDir, "migrations.js"), { force: true });
}
async function main(): Promise<void> {
const packageRoot = resolve(import.meta.dirname, "..", "..", ".."); // packages/backend
const actorsRoot = resolve(packageRoot, "src", "actors");
await walkDirectories(actorsRoot, async (dir) => {
if (dir.endsWith(`${join("db", "drizzle")}`)) {
await generateOne(dir);
}
});
}
main().catch((error: unknown) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
// eslint-disable-next-line no-console
console.error(message);
process.exitCode = 1;
});

View file

@ -0,0 +1,53 @@
import type { AppConfig } from "@sandbox-agent/foundry-shared";
import type { BackendDriver } from "../driver.js";
import type { NotificationService } from "../notifications/index.js";
import type { ProviderRegistry } from "../providers/index.js";
import type { AppShellServices } from "../services/app-shell-runtime.js";
let runtimeConfig: AppConfig | null = null;
let providerRegistry: ProviderRegistry | null = null;
let notificationService: NotificationService | null = null;
let runtimeDriver: BackendDriver | null = null;
let appShellServices: AppShellServices | null = null;
export function initActorRuntimeContext(
config: AppConfig,
providers: ProviderRegistry,
notifications?: NotificationService,
driver?: BackendDriver,
appShell?: AppShellServices,
): void {
runtimeConfig = config;
providerRegistry = providers;
notificationService = notifications ?? null;
runtimeDriver = driver ?? null;
appShellServices = appShell ?? null;
}
export function getActorRuntimeContext(): {
config: AppConfig;
providers: ProviderRegistry;
notifications: NotificationService | null;
driver: BackendDriver;
appShell: AppShellServices;
} {
if (!runtimeConfig || !providerRegistry) {
throw new Error("Actor runtime context not initialized");
}
if (!runtimeDriver) {
throw new Error("Actor runtime context missing driver");
}
if (!appShellServices) {
throw new Error("Actor runtime context missing app shell services");
}
return {
config: runtimeConfig,
providers: providerRegistry,
notifications: notificationService,
driver: runtimeDriver,
appShell: appShellServices,
};
}

View file

@ -0,0 +1,112 @@
import type { TaskStatus, ProviderId } from "@sandbox-agent/foundry-shared";
export interface TaskCreatedEvent {
workspaceId: string;
repoId: string;
taskId: string;
providerId: ProviderId;
branchName: string;
title: string;
}
export interface TaskStatusEvent {
workspaceId: string;
repoId: string;
taskId: string;
status: TaskStatus;
message: string;
}
export interface ProjectSnapshotEvent {
workspaceId: string;
repoId: string;
updatedAt: number;
}
export interface AgentStartedEvent {
workspaceId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentIdleEvent {
workspaceId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentErrorEvent {
workspaceId: string;
repoId: string;
taskId: string;
message: string;
}
export interface PrCreatedEvent {
workspaceId: string;
repoId: string;
taskId: string;
prNumber: number;
url: string;
}
export interface PrClosedEvent {
workspaceId: string;
repoId: string;
taskId: string;
prNumber: number;
merged: boolean;
}
export interface PrReviewEvent {
workspaceId: string;
repoId: string;
taskId: string;
prNumber: number;
reviewer: string;
status: string;
}
export interface CiStatusChangedEvent {
workspaceId: string;
repoId: string;
taskId: string;
prNumber: number;
status: string;
}
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
export interface TaskStepEvent {
workspaceId: string;
repoId: string;
taskId: string;
step: TaskStepName;
status: TaskStepStatus;
message: string;
}
export interface BranchSwitchedEvent {
workspaceId: string;
repoId: string;
taskId: string;
branchName: string;
}
export interface SessionAttachedEvent {
workspaceId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface BranchSyncedEvent {
workspaceId: string;
repoId: string;
taskId: string;
branchName: string;
strategy: string;
}

View file

@ -0,0 +1,127 @@
import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js";
import type { ProviderId } from "@sandbox-agent/foundry-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 getTask(c: any, workspaceId: string, repoId: string, taskId: string) {
return actorClient(c).task.get(taskKey(workspaceId, repoId, taskId));
}
export async function getOrCreateTask(c: any, workspaceId: string, repoId: string, taskId: string, createWithInput: Record<string, unknown>) {
return await actorClient(c).task.getOrCreate(taskKey(workspaceId, repoId, taskId), {
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 getOrCreateTaskStatusSync(
c: any,
workspaceId: string,
repoId: string,
taskId: string,
sandboxId: string,
sessionId: string,
createWithInput: Record<string, unknown>,
) {
return await actorClient(c).taskStatusSync.getOrCreate(taskStatusSyncKey(workspaceId, repoId, taskId, 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 selfTaskStatusSync(c: any) {
return actorClient(c).taskStatusSync.getForId(c.actorId);
}
export function selfHistory(c: any) {
return actorClient(c).history.getForId(c.actorId);
}
export function selfTask(c: any) {
return actorClient(c).task.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);
}

View file

@ -0,0 +1,5 @@
import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const historyDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/history/db/drizzle",
schema: "./src/actors/history/db/schema.ts",
});

View file

@ -0,0 +1,8 @@
CREATE TABLE `events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`task_id` text,
`branch_name` text,
`kind` text NOT NULL,
`payload_json` text NOT NULL,
`created_at` integer NOT NULL
);

View file

@ -0,0 +1,70 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9d9ebe3c-8341-449c-bd14-2b6fd62853a1",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payload_json": {
"name": "payload_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1770924375133,
"tag": "0000_watery_bushwacker",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,29 @@
// 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: 1770924375133,
tag: "0000_watery_bushwacker",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`events\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`task_id\` text,
\`branch_name\` text,
\`kind\` text NOT NULL,
\`payload_json\` text NOT NULL,
\`created_at\` integer NOT NULL
);
`,
} as const,
};

View file

@ -0,0 +1,10 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
export const events = sqliteTable("events", {
id: integer("id").primaryKey({ autoIncrement: true }),
taskId: text("task_id"),
branchName: text("branch_name"),
kind: text("kind").notNull(),
payloadJson: text("payload_json").notNull(),
createdAt: integer("created_at").notNull(),
});

View file

@ -0,0 +1,111 @@
// @ts-nocheck
import { and, desc, eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { HistoryEvent } from "@sandbox-agent/foundry-shared";
import { selfHistory } from "../handles.js";
import { historyDb } from "./db/db.js";
import { events } from "./db/schema.js";
export interface HistoryInput {
workspaceId: string;
repoId: string;
}
export interface AppendHistoryCommand {
kind: string;
taskId?: string;
branchName?: string;
payload: Record<string, unknown>;
}
export interface ListHistoryParams {
branch?: string;
taskId?: string;
limit?: number;
}
const HISTORY_QUEUE_NAMES = ["history.command.append"] as const;
async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promise<void> {
const now = Date.now();
await loopCtx.db
.insert(events)
.values({
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
createdAt: now,
})
.run();
}
async function runHistoryWorkflow(ctx: any): Promise<void> {
await ctx.loop("history-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-history-command", {
names: [...HISTORY_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "history.command.append") {
await loopCtx.step("append-history-row", async () => appendHistoryRow(loopCtx, msg.body as AppendHistoryCommand));
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}
export const history = actor({
db: historyDb,
queues: {
"history.command.append": queue(),
},
createState: (_c, input: HistoryInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
}),
actions: {
async append(c, command: AppendHistoryCommand): Promise<void> {
const self = selfHistory(c);
await self.send("history.command.append", command, { wait: true, timeout: 15_000 });
},
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
const whereParts = [];
if (params?.taskId) {
whereParts.push(eq(events.taskId, params.taskId));
}
if (params?.branch) {
whereParts.push(eq(events.branchName, params.branch));
}
const base = c.db
.select({
id: events.id,
taskId: events.taskId,
branchName: events.branchName,
kind: events.kind,
payloadJson: events.payloadJson,
createdAt: events.createdAt,
})
.from(events);
const rows = await (whereParts.length > 0 ? base.where(and(...whereParts)) : base)
.orderBy(desc(events.createdAt))
.limit(params?.limit ?? 100)
.all();
return rows.map((row) => ({
...row,
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
}));
},
},
run: workflow(runHistoryWorkflow),
});

View file

@ -0,0 +1,54 @@
import { setup } from "rivetkit";
import { taskStatusSync } from "./task-status-sync/index.js";
import { task } from "./task/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";
export 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,
task,
sandboxInstance,
history,
projectPrSync,
projectBranchSync,
taskStatusSync,
},
managerPort: resolveManagerPort(),
managerHost: resolveManagerHost(),
});
export * from "./context.js";
export * from "./events.js";
export * from "./task-status-sync/index.js";
export * from "./task/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";

View file

@ -0,0 +1,34 @@
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 taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "task", taskId];
}
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 taskStatusSyncKey(workspaceId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
}

View file

@ -0,0 +1,27 @@
export function resolveErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function isActorNotFoundError(error: unknown): boolean {
return resolveErrorMessage(error).includes("Actor not found:");
}
export function resolveErrorStack(error: unknown): string | undefined {
if (error instanceof Error && typeof error.stack === "string") {
return error.stack;
}
return undefined;
}
export function logActorWarning(scope: string, message: string, context?: Record<string, unknown>): void {
const payload = {
scope,
message,
...(context ?? {}),
};
// eslint-disable-next-line no-console
console.warn("[foundry][actor:warn]", payload);
}

View file

@ -0,0 +1,189 @@
import { Loop } from "rivetkit/workflow";
import { normalizeMessages } from "../services/queue.js";
export interface PollingControlState {
intervalMs: number;
running: boolean;
}
export interface PollingControlQueueNames {
start: string;
stop: string;
setInterval: string;
force: string;
}
export interface PollingQueueMessage {
name: string;
body: unknown;
complete(response: unknown): Promise<void>;
}
interface PollingActorContext<TState extends PollingControlState> {
state: TState;
abortSignal: AbortSignal;
queue: {
nextBatch(options: { names: readonly string[]; timeout: number; count: number; completable: true }): Promise<PollingQueueMessage[]>;
};
}
interface RunPollingOptions<TState extends PollingControlState> {
control: PollingControlQueueNames;
onPoll(c: PollingActorContext<TState>): Promise<void>;
}
export async function runPollingControlLoop<TState extends PollingControlState>(
c: PollingActorContext<TState>,
options: RunPollingOptions<TState>,
): Promise<void> {
while (!c.abortSignal.aborted) {
const messages = normalizeMessages(
await c.queue.nextBatch({
names: [options.control.start, options.control.stop, options.control.setInterval, options.control.force],
timeout: Math.max(500, c.state.intervalMs),
count: 16,
completable: true,
}),
) as PollingQueueMessage[];
if (messages.length === 0) {
if (!c.state.running) {
continue;
}
await options.onPoll(c);
continue;
}
for (const msg of messages) {
if (msg.name === options.control.start) {
c.state.running = true;
await msg.complete({ ok: true });
continue;
}
if (msg.name === options.control.stop) {
c.state.running = false;
await msg.complete({ ok: true });
continue;
}
if (msg.name === options.control.setInterval) {
const intervalMs = Number((msg.body as { intervalMs?: unknown })?.intervalMs);
c.state.intervalMs = Number.isFinite(intervalMs) ? Math.max(500, intervalMs) : c.state.intervalMs;
await msg.complete({ ok: true });
continue;
}
if (msg.name === options.control.force) {
await options.onPoll(c);
await msg.complete({ ok: true });
}
}
}
}
interface WorkflowPollingActorContext<TState extends PollingControlState> {
state: TState;
loop(config: { name: string; historyEvery: number; historyKeep: number; run(ctx: WorkflowPollingActorContext<TState>): Promise<unknown> }): Promise<void>;
}
interface WorkflowPollingQueueMessage extends PollingQueueMessage {}
interface WorkflowPollingLoopContext<TState extends PollingControlState> {
state: TState;
queue: {
nextBatch(
name: string,
options: {
names: readonly string[];
timeout: number;
count: number;
completable: true;
},
): Promise<WorkflowPollingQueueMessage[]>;
};
step<T>(
nameOrConfig:
| string
| {
name: string;
timeout?: number;
run: () => Promise<T>;
},
run?: () => Promise<T>,
): Promise<T>;
}
export async function runWorkflowPollingLoop<TState extends PollingControlState>(
ctx: any,
options: RunPollingOptions<TState> & { loopName: string },
): Promise<void> {
await ctx.loop(options.loopName, async (loopCtx: WorkflowPollingLoopContext<TState>) => {
const control = await loopCtx.step("read-control-state", async () => ({
intervalMs: Math.max(500, Number(loopCtx.state.intervalMs) || 500),
running: Boolean(loopCtx.state.running),
}));
const messages = normalizeMessages(
await loopCtx.queue.nextBatch("next-polling-control-batch", {
names: [options.control.start, options.control.stop, options.control.setInterval, options.control.force],
timeout: control.running ? control.intervalMs : 60_000,
count: 16,
completable: true,
}),
) as WorkflowPollingQueueMessage[];
if (messages.length === 0) {
if (control.running) {
await loopCtx.step({
name: "poll-tick",
timeout: 5 * 60_000,
run: async () => {
await options.onPoll(loopCtx as unknown as PollingActorContext<TState>);
},
});
}
return Loop.continue(undefined);
}
for (const msg of messages) {
if (msg.name === options.control.start) {
await loopCtx.step("control-start", async () => {
loopCtx.state.running = true;
});
await msg.complete({ ok: true });
continue;
}
if (msg.name === options.control.stop) {
await loopCtx.step("control-stop", async () => {
loopCtx.state.running = false;
});
await msg.complete({ ok: true });
continue;
}
if (msg.name === options.control.setInterval) {
await loopCtx.step("control-set-interval", async () => {
const intervalMs = Number((msg.body as { intervalMs?: unknown })?.intervalMs);
loopCtx.state.intervalMs = Number.isFinite(intervalMs) ? Math.max(500, intervalMs) : loopCtx.state.intervalMs;
});
await msg.complete({ ok: true });
continue;
}
if (msg.name === options.control.force) {
await loopCtx.step({
name: "control-force",
timeout: 5 * 60_000,
run: async () => {
await options.onPoll(loopCtx as unknown as PollingActorContext<TState>);
},
});
await msg.complete({ ok: true });
}
}
return Loop.continue(undefined);
});
}

View file

@ -0,0 +1,176 @@
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),
});
}
},
});
}),
});

View file

@ -0,0 +1,94 @@
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),
});
}
},
});
}),
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const projectDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/project/db/drizzle",
schema: "./src/actors/project/db/schema.ts",
});

View file

@ -0,0 +1,27 @@
CREATE TABLE `branches` (
`branch_name` text PRIMARY KEY NOT NULL,
`commit_sha` text NOT NULL,
`worktree_path` text,
`parent_branch` text,
`diff_stat` text,
`has_unpushed` integer,
`conflicts_with_main` integer,
`first_seen_at` integer,
`last_seen_at` integer,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `pr_cache` (
`branch_name` text PRIMARY KEY NOT NULL,
`pr_number` integer NOT NULL,
`state` text NOT NULL,
`title` text NOT NULL,
`pr_url` text,
`pr_author` text,
`is_draft` integer,
`ci_status` text,
`review_status` text,
`reviewer` text,
`fetched_at` integer,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1,7 @@
CREATE TABLE `repo_meta` (
`id` integer PRIMARY KEY NOT NULL,
`remote_url` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE `branches` DROP COLUMN `worktree_path`;

View file

@ -0,0 +1,6 @@
CREATE TABLE `task_index` (
`task_id` text PRIMARY KEY NOT NULL,
`branch_name` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1 @@
ALTER TABLE `branches` ADD `tracked_in_stack` integer;

View file

@ -0,0 +1,192 @@
{
"version": "6",
"dialect": "sqlite",
"id": "03d97613-0108-4197-8660-5f2af5409fe6",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"branches": {
"name": "branches",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worktree_path": {
"name": "worktree_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_branch": {
"name": "parent_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"diff_stat": {
"name": "diff_stat",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"has_unpushed": {
"name": "has_unpushed",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"conflicts_with_main": {
"name": "conflicts_with_main",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"first_seen_at": {
"name": "first_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_seen_at": {
"name": "last_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pr_cache": {
"name": "pr_cache",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"pr_number": {
"name": "pr_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pr_url": {
"name": "pr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pr_author": {
"name": "pr_author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_draft": {
"name": "is_draft",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ci_status": {
"name": "ci_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"review_status": {
"name": "review_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewer": {
"name": "reviewer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fetched_at": {
"name": "fetched_at",
"type": "integer",
"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": {}
}
}

View file

@ -0,0 +1,216 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e6d294b6-27ce-424b-a3b3-c100b42e628b",
"prevId": "03d97613-0108-4197-8660-5f2af5409fe6",
"tables": {
"branches": {
"name": "branches",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_branch": {
"name": "parent_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"diff_stat": {
"name": "diff_stat",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"has_unpushed": {
"name": "has_unpushed",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"conflicts_with_main": {
"name": "conflicts_with_main",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"first_seen_at": {
"name": "first_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_seen_at": {
"name": "last_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pr_cache": {
"name": "pr_cache",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"pr_number": {
"name": "pr_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pr_url": {
"name": "pr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pr_author": {
"name": "pr_author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_draft": {
"name": "is_draft",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ci_status": {
"name": "ci_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"review_status": {
"name": "review_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewer": {
"name": "reviewer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fetched_at": {
"name": "fetched_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repo_meta": {
"name": "repo_meta",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"remote_url": {
"name": "remote_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": {}
}
}

View file

@ -0,0 +1,254 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ac89870f-1630-4a16-9606-7b1225f6da8a",
"prevId": "e6d294b6-27ce-424b-a3b3-c100b42e628b",
"tables": {
"branches": {
"name": "branches",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_branch": {
"name": "parent_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"diff_stat": {
"name": "diff_stat",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"has_unpushed": {
"name": "has_unpushed",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"conflicts_with_main": {
"name": "conflicts_with_main",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"first_seen_at": {
"name": "first_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_seen_at": {
"name": "last_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_index": {
"name": "task_index",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"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": {}
},
"pr_cache": {
"name": "pr_cache",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"pr_number": {
"name": "pr_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pr_url": {
"name": "pr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pr_author": {
"name": "pr_author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_draft": {
"name": "is_draft",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ci_status": {
"name": "ci_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"review_status": {
"name": "review_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewer": {
"name": "reviewer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fetched_at": {
"name": "fetched_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repo_meta": {
"name": "repo_meta",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"remote_url": {
"name": "remote_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": {}
}
}

View file

@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1770924376062,
"tag": "0000_stormy_the_hunter",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1770947252449,
"tag": "0001_wild_carlie_cooper",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1771276338465,
"tag": "0002_far_war_machine",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1771369000000,
"tag": "0003_busy_legacy",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,81 @@
// 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: 1770924376062,
tag: "0000_stormy_the_hunter",
breakpoints: true,
},
{
idx: 1,
when: 1770947252449,
tag: "0001_wild_carlie_cooper",
breakpoints: true,
},
{
idx: 2,
when: 1771276338465,
tag: "0002_far_war_machine",
breakpoints: true,
},
{
idx: 3,
when: 1771369000000,
tag: "0003_busy_legacy",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`branches\` (
\`branch_name\` text PRIMARY KEY NOT NULL,
\`commit_sha\` text NOT NULL,
\`worktree_path\` text,
\`parent_branch\` text,
\`diff_stat\` text,
\`has_unpushed\` integer,
\`conflicts_with_main\` integer,
\`first_seen_at\` integer,
\`last_seen_at\` integer,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`pr_cache\` (
\`branch_name\` text PRIMARY KEY NOT NULL,
\`pr_number\` integer NOT NULL,
\`state\` text NOT NULL,
\`title\` text NOT NULL,
\`pr_url\` text,
\`pr_author\` text,
\`is_draft\` integer,
\`ci_status\` text,
\`review_status\` text,
\`reviewer\` text,
\`fetched_at\` integer,
\`updated_at\` integer NOT NULL
);
`,
m0001: `CREATE TABLE \`repo_meta\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`remote_url\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
m0002: `CREATE TABLE \`task_index\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0003: `ALTER TABLE \`branches\` ADD \`tracked_in_stack\` integer;`,
} as const,
};

View file

@ -0,0 +1,44 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per project actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed.
export const branches = sqliteTable("branches", {
branchName: text("branch_name").notNull().primaryKey(),
commitSha: text("commit_sha").notNull(),
parentBranch: text("parent_branch"),
trackedInStack: integer("tracked_in_stack"),
diffStat: text("diff_stat"),
hasUnpushed: integer("has_unpushed"),
conflictsWithMain: integer("conflicts_with_main"),
firstSeenAt: integer("first_seen_at"),
lastSeenAt: integer("last_seen_at"),
updatedAt: integer("updated_at").notNull(),
});
export const repoMeta = sqliteTable("repo_meta", {
id: integer("id").primaryKey(),
remoteUrl: text("remote_url").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const prCache = sqliteTable("pr_cache", {
branchName: text("branch_name").notNull().primaryKey(),
prNumber: integer("pr_number").notNull(),
state: text("state").notNull(),
title: text("title").notNull(),
prUrl: text("pr_url"),
prAuthor: text("pr_author"),
isDraft: integer("is_draft"),
ciStatus: text("ci_status"),
reviewStatus: text("review_status"),
reviewer: text("reviewer"),
fetchedAt: integer("fetched_at"),
updatedAt: integer("updated_at").notNull(),
});
export const taskIndex = sqliteTable("task_index", {
taskId: text("task_id").notNull().primaryKey(),
branchName: text("branch_name"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -0,0 +1,28 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { projectDb } from "./db/db.js";
import { PROJECT_QUEUE_NAMES, projectActions, runProjectWorkflow } from "./actions.js";
export interface ProjectInput {
workspaceId: string;
repoId: string;
remoteUrl: string;
}
export const project = actor({
db: projectDb,
queues: Object.fromEntries(PROJECT_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000,
},
createState: (_c, input: ProjectInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
localPath: null as string | null,
syncActorsStarted: false,
taskIndexHydrated: false,
}),
actions: projectActions,
run: workflow(runProjectWorkflow),
});

View file

@ -0,0 +1,69 @@
export interface StackEntry {
branchName: string;
parentBranch: string | null;
}
export interface OrderedBranchRow {
branchName: string;
parentBranch: string | null;
updatedAt: number;
}
export function normalizeParentBranch(branchName: string, parentBranch: string | null | undefined): string | null {
const parent = parentBranch?.trim() || null;
if (!parent || parent === branchName) {
return null;
}
return parent;
}
export function parentLookupFromStack(entries: StackEntry[]): Map<string, string | null> {
const lookup = new Map<string, string | null>();
for (const entry of entries) {
const branchName = entry.branchName.trim();
if (!branchName) {
continue;
}
lookup.set(branchName, normalizeParentBranch(branchName, entry.parentBranch));
}
return lookup;
}
export function sortBranchesForOverview(rows: OrderedBranchRow[]): OrderedBranchRow[] {
const byName = new Map(rows.map((row) => [row.branchName, row]));
const depthMemo = new Map<string, number>();
const computing = new Set<string>();
const depthFor = (branchName: string): number => {
const cached = depthMemo.get(branchName);
if (cached != null) {
return cached;
}
if (computing.has(branchName)) {
return 999;
}
computing.add(branchName);
const row = byName.get(branchName);
const parent = row?.parentBranch;
let depth = 0;
if (parent && parent !== branchName && byName.has(parent)) {
depth = Math.min(998, depthFor(parent) + 1);
}
computing.delete(branchName);
depthMemo.set(branchName, depth);
return depth;
};
return [...rows].sort((a, b) => {
const da = depthFor(a.branchName);
const db = depthFor(b.branchName);
if (da !== db) {
return da - db;
}
if (a.updatedAt !== b.updatedAt) {
return b.updatedAt - a.updatedAt;
}
return a.branchName.localeCompare(b.branchName);
});
}

View file

@ -0,0 +1,5 @@
import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const sandboxInstanceDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/sandbox-instance/db/drizzle",
schema: "./src/actors/sandbox-instance/db/schema.ts",
});

View file

@ -0,0 +1,6 @@
CREATE TABLE `sandbox_instance` (
`id` integer PRIMARY KEY NOT NULL,
`metadata_json` text NOT NULL,
`status` text NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1,27 @@
CREATE TABLE `sandbox_sessions` (
`id` text PRIMARY KEY NOT NULL,
`agent` text NOT NULL,
`agent_session_id` text NOT NULL,
`last_connection_id` text NOT NULL,
`created_at` integer NOT NULL,
`destroyed_at` integer,
`session_init_json` text
);
--> statement-breakpoint
CREATE TABLE `sandbox_session_events` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`event_index` integer NOT NULL,
`created_at` integer NOT NULL,
`connection_id` text NOT NULL,
`sender` text NOT NULL,
`payload_json` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `sandbox_sessions_created_at_idx` ON `sandbox_sessions` (`created_at`);
--> statement-breakpoint
CREATE INDEX `sandbox_session_events_session_id_event_index_idx` ON `sandbox_session_events` (`session_id`,`event_index`);
--> statement-breakpoint
CREATE INDEX `sandbox_session_events_session_id_created_at_idx` ON `sandbox_session_events` (`session_id`,`created_at`);

View file

@ -0,0 +1,56 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ef8a919c-64f0-46d9-b8ed-a15f039e6ba7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"sandbox_instance": {
"name": "sandbox_instance",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"metadata_json": {
"name": "metadata_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": {}
}
}

View file

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1770924375604,
"tag": "0000_broad_tyrannus",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1776482400000,
"tag": "0001_sandbox_sessions",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,61 @@
// 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: 1770924375604,
tag: "0000_broad_tyrannus",
breakpoints: true,
},
{
idx: 1,
when: 1776482400000,
tag: "0001_sandbox_sessions",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`sandbox_instance\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`metadata_json\` text NOT NULL,
\`status\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `CREATE TABLE \`sandbox_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`agent\` text NOT NULL,
\`agent_session_id\` text NOT NULL,
\`last_connection_id\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`destroyed_at\` integer,
\`session_init_json\` text
);
--> statement-breakpoint
CREATE TABLE \`sandbox_session_events\` (
\`id\` text PRIMARY KEY NOT NULL,
\`session_id\` text NOT NULL,
\`event_index\` integer NOT NULL,
\`created_at\` integer NOT NULL,
\`connection_id\` text NOT NULL,
\`sender\` text NOT NULL,
\`payload_json\` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX \`sandbox_sessions_created_at_idx\` ON \`sandbox_sessions\` (\`created_at\`);
--> statement-breakpoint
CREATE INDEX \`sandbox_session_events_session_id_event_index_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`event_index\`);
--> statement-breakpoint
CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`);
`,
} as const,
};

View file

@ -0,0 +1,31 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per sandbox-instance actor instance.
export const sandboxInstance = sqliteTable("sandbox_instance", {
id: integer("id").primaryKey(),
metadataJson: text("metadata_json").notNull(),
status: text("status").notNull(),
updatedAt: integer("updated_at").notNull(),
});
// Persist sandbox-agent sessions/events in SQLite instead of actor state so they survive
// serverless actor evictions and backend restarts.
export const sandboxSessions = sqliteTable("sandbox_sessions", {
id: text("id").notNull().primaryKey(),
agent: text("agent").notNull(),
agentSessionId: text("agent_session_id").notNull(),
lastConnectionId: text("last_connection_id").notNull(),
createdAt: integer("created_at").notNull(),
destroyedAt: integer("destroyed_at"),
sessionInitJson: text("session_init_json"),
});
export const sandboxSessionEvents = sqliteTable("sandbox_session_events", {
id: text("id").notNull().primaryKey(),
sessionId: text("session_id").notNull(),
eventIndex: integer("event_index").notNull(),
createdAt: integer("created_at").notNull(),
connectionId: text("connection_id").notNull(),
sender: text("sender").notNull(),
payloadJson: text("payload_json").notNull(),
});

View file

@ -0,0 +1,636 @@
import { setTimeout as delay } from "node:timers/promises";
import { eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { ProviderId } from "@sandbox-agent/foundry-shared";
import type {
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
import { sandboxInstanceDb } from "./db/db.js";
import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js";
import { SandboxInstancePersistDriver } from "./persist.js";
import { getActorRuntimeContext } from "../context.js";
import { selfSandboxInstance } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { expectQueueResponse } from "../../services/queue.js";
export interface SandboxInstanceInput {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
}
interface SandboxAgentConnection {
endpoint: string;
token?: string;
}
const SANDBOX_ROW_ID = 1;
const CREATE_SESSION_MAX_ATTEMPTS = 3;
const CREATE_SESSION_RETRY_BASE_MS = 1_000;
const CREATE_SESSION_STEP_TIMEOUT_MS = 10 * 60_000;
function normalizeStatusFromEventPayload(payload: unknown): "running" | "idle" | "error" | null {
if (payload && typeof payload === "object") {
const envelope = payload as {
error?: unknown;
method?: unknown;
result?: unknown;
};
if (envelope.error) {
return "error";
}
if (envelope.result && typeof envelope.result === "object") {
const stopReason = (envelope.result as { stopReason?: unknown }).stopReason;
if (typeof stopReason === "string" && stopReason.length > 0) {
return "idle";
}
}
if (typeof envelope.method === "string") {
const lowered = envelope.method.toLowerCase();
if (lowered.includes("error") || lowered.includes("failed")) {
return "error";
}
if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
return "idle";
}
}
}
return null;
}
function stringifyJson(value: unknown): string {
return JSON.stringify(value, (_key, item) => {
if (typeof item === "bigint") return item.toString();
return item;
});
}
function parseMetadata(metadataJson: string): Record<string, unknown> {
try {
const parsed = JSON.parse(metadataJson) as unknown;
if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
return {};
} catch {
return {};
}
}
async function loadPersistedAgentConfig(c: any): Promise<SandboxAgentConnection | null> {
try {
const row = await c.db
.select({ metadataJson: sandboxInstanceTable.metadataJson })
.from(sandboxInstanceTable)
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
.get();
if (row?.metadataJson) {
const metadata = parseMetadata(row.metadataJson);
const endpoint = typeof metadata.agentEndpoint === "string" ? metadata.agentEndpoint.trim() : "";
const token = typeof metadata.agentToken === "string" ? metadata.agentToken.trim() : "";
if (endpoint) {
return token ? { endpoint, token } : { endpoint };
}
}
} catch {
return null;
}
return null;
}
async function loadFreshDaytonaAgentConfig(c: any): Promise<SandboxAgentConnection> {
const { config, driver } = getActorRuntimeContext();
const daytona = driver.daytona.createClient({
apiUrl: config.providers.daytona.endpoint,
apiKey: config.providers.daytona.apiKey,
});
const sandbox = await daytona.getSandbox(c.state.sandboxId);
const state = String(sandbox.state ?? "unknown").toLowerCase();
if (state !== "started" && state !== "running") {
await daytona.startSandbox(c.state.sandboxId, 60);
}
const preview = await daytona.getPreviewEndpoint(c.state.sandboxId, 2468);
return preview.token ? { endpoint: preview.url, token: preview.token } : { endpoint: preview.url };
}
async function loadFreshProviderAgentConfig(c: any): Promise<SandboxAgentConnection> {
const { providers } = getActorRuntimeContext();
const provider = providers.get(c.state.providerId);
return await provider.ensureSandboxAgent({
workspaceId: c.state.workspaceId,
sandboxId: c.state.sandboxId,
});
}
async function loadAgentConfig(c: any): Promise<SandboxAgentConnection> {
const persisted = await loadPersistedAgentConfig(c);
if (c.state.providerId === "daytona") {
// Keep one stable signed preview endpoint per sandbox-instance actor.
// Rotating preview URLs on every call fragments SDK client state (sessions/events)
// because client caching keys by endpoint.
if (persisted) {
return persisted;
}
return await loadFreshDaytonaAgentConfig(c);
}
// Local sandboxes are tied to the current backend process, so the sandbox-agent
// token can rotate on restart. Always refresh from the provider instead of
// trusting persisted metadata.
if (c.state.providerId === "local") {
return await loadFreshProviderAgentConfig(c);
}
if (persisted) {
return persisted;
}
return await loadFreshProviderAgentConfig(c);
}
async function derivePersistedSessionStatus(
persist: SandboxInstancePersistDriver,
sessionId: string,
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
const session = await persist.getSession(sessionId);
if (!session) {
return { id: sessionId, status: "error" };
}
if (session.destroyedAt) {
return { id: sessionId, status: "idle" };
}
const events = await persist.listEvents({
sessionId,
limit: 25,
});
for (let index = events.items.length - 1; index >= 0; index -= 1) {
const event = events.items[index];
if (!event) continue;
const status = normalizeStatusFromEventPayload(event.payload);
if (status) {
return { id: sessionId, status };
}
}
return { id: sessionId, status: "idle" };
}
function isTransientSessionCreateError(detail: string): boolean {
const lowered = detail.toLowerCase();
if (lowered.includes("timed out") || lowered.includes("timeout") || lowered.includes("504") || lowered.includes("gateway timeout")) {
// ACP timeout errors are expensive and usually deterministic for the same
// request; immediate retries spawn additional sessions/processes and make
// recovery harder.
return false;
}
return (
lowered.includes("502") || lowered.includes("503") || lowered.includes("bad gateway") || lowered.includes("econnreset") || lowered.includes("econnrefused")
);
}
interface EnsureSandboxCommand {
metadata: Record<string, unknown>;
status: string;
agentEndpoint?: string;
agentToken?: string;
}
interface HealthSandboxCommand {
status: string;
message: string;
}
interface CreateSessionCommand {
prompt: string;
cwd?: string;
agent?: "claude" | "codex" | "opencode";
}
interface CreateSessionResult {
id: string | null;
status: "running" | "idle" | "error";
error?: string;
}
interface ListSessionsCommand {
cursor?: string;
limit?: number;
}
interface ListSessionEventsCommand {
sessionId: string;
cursor?: string;
limit?: number;
}
interface SendPromptCommand {
sessionId: string;
prompt: string;
notification?: boolean;
}
interface SessionStatusCommand {
sessionId: string;
}
interface SessionControlCommand {
sessionId: string;
}
const SANDBOX_INSTANCE_QUEUE_NAMES = [
"sandboxInstance.command.ensure",
"sandboxInstance.command.updateHealth",
"sandboxInstance.command.destroy",
"sandboxInstance.command.createSession",
"sandboxInstance.command.sendPrompt",
"sandboxInstance.command.cancelSession",
"sandboxInstance.command.destroySession",
] as const;
type SandboxInstanceQueueName = (typeof SANDBOX_INSTANCE_QUEUE_NAMES)[number];
function sandboxInstanceWorkflowQueueName(name: SandboxInstanceQueueName): SandboxInstanceQueueName {
return name;
}
async function getSandboxAgentClient(c: any) {
const { driver } = getActorRuntimeContext();
const persist = new SandboxInstancePersistDriver(c.db);
const { endpoint, token } = await loadAgentConfig(c);
return driver.sandboxAgent.createClient({
endpoint,
token,
persist,
});
}
function broadcastProcessesUpdated(c: any): void {
c.broadcast("processesUpdated", {
sandboxId: c.state.sandboxId,
at: Date.now(),
});
}
async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Promise<void> {
const now = Date.now();
const metadata = {
...command.metadata,
agentEndpoint: command.agentEndpoint ?? null,
agentToken: command.agentToken ?? null,
};
const metadataJson = stringifyJson(metadata);
await c.db
.insert(sandboxInstanceTable)
.values({
id: SANDBOX_ROW_ID,
metadataJson,
status: command.status,
updatedAt: now,
})
.onConflictDoUpdate({
target: sandboxInstanceTable.id,
set: {
metadataJson,
status: command.status,
updatedAt: now,
},
})
.run();
}
async function updateHealthMutation(c: any, command: HealthSandboxCommand): Promise<void> {
await c.db
.update(sandboxInstanceTable)
.set({
status: `${command.status}:${command.message}`,
updatedAt: Date.now(),
})
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
.run();
}
async function destroySandboxMutation(c: any): Promise<void> {
await c.db.delete(sandboxInstanceTable).where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)).run();
}
async function createSessionMutation(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
let lastDetail = "sandbox-agent createSession failed";
let attemptsMade = 0;
for (let attempt = 1; attempt <= CREATE_SESSION_MAX_ATTEMPTS; attempt += 1) {
attemptsMade = attempt;
try {
const client = await getSandboxAgentClient(c);
const session = await client.createSession({
prompt: command.prompt,
cwd: command.cwd,
agent: command.agent,
});
return { id: session.id, status: session.status };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
lastDetail = detail;
const retryable = isTransientSessionCreateError(detail);
const canRetry = retryable && attempt < CREATE_SESSION_MAX_ATTEMPTS;
if (!canRetry) {
break;
}
const waitMs = CREATE_SESSION_RETRY_BASE_MS * attempt;
logActorWarning("sandbox-instance", "createSession transient failure; retrying", {
workspaceId: c.state.workspaceId,
providerId: c.state.providerId,
sandboxId: c.state.sandboxId,
attempt,
maxAttempts: CREATE_SESSION_MAX_ATTEMPTS,
waitMs,
error: detail,
});
await delay(waitMs);
}
}
const attemptLabel = attemptsMade === 1 ? "attempt" : "attempts";
return {
id: null,
status: "error",
error: `sandbox-agent createSession failed after ${attemptsMade} ${attemptLabel}: ${lastDetail}`,
};
}
async function sendPromptMutation(c: any, command: SendPromptCommand): Promise<void> {
const client = await getSandboxAgentClient(c);
await client.sendPrompt({
sessionId: command.sessionId,
prompt: command.prompt,
notification: command.notification,
});
}
async function cancelSessionMutation(c: any, command: SessionControlCommand): Promise<void> {
const client = await getSandboxAgentClient(c);
await client.cancelSession(command.sessionId);
}
async function destroySessionMutation(c: any, command: SessionControlCommand): Promise<void> {
const client = await getSandboxAgentClient(c);
await client.destroySession(command.sessionId);
}
async function runSandboxInstanceWorkflow(ctx: any): Promise<void> {
await ctx.loop("sandbox-instance-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-sandbox-instance-command", {
names: [...SANDBOX_INSTANCE_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.ensure") {
await loopCtx.step("sandbox-instance-ensure", async () => ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand));
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.updateHealth") {
await loopCtx.step("sandbox-instance-update-health", async () => updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand));
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.destroy") {
await loopCtx.step("sandbox-instance-destroy", async () => destroySandboxMutation(loopCtx));
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.createSession") {
const result = await loopCtx.step({
name: "sandbox-instance-create-session",
timeout: CREATE_SESSION_STEP_TIMEOUT_MS,
run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.sendPrompt") {
await loopCtx.step("sandbox-instance-send-prompt", async () => sendPromptMutation(loopCtx, msg.body as SendPromptCommand));
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.cancelSession") {
await loopCtx.step("sandbox-instance-cancel-session", async () => cancelSessionMutation(loopCtx, msg.body as SessionControlCommand));
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.destroySession") {
await loopCtx.step("sandbox-instance-destroy-session", async () => destroySessionMutation(loopCtx, msg.body as SessionControlCommand));
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}
export const sandboxInstance = actor({
db: sandboxInstanceDb,
queues: Object.fromEntries(SANDBOX_INSTANCE_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000,
},
createState: (_c, input: SandboxInstanceInput) => ({
workspaceId: input.workspaceId,
providerId: input.providerId,
sandboxId: input.sandboxId,
}),
actions: {
async sandboxAgentConnection(c: any): Promise<SandboxAgentConnection> {
return await loadAgentConfig(c);
},
async createProcess(c: any, request: ProcessCreateRequest): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const created = await client.createProcess(request);
broadcastProcessesUpdated(c);
return created;
},
async listProcesses(c: any): Promise<{ processes: ProcessInfo[] }> {
const client = await getSandboxAgentClient(c);
return await client.listProcesses();
},
async getProcessLogs(c: any, request: { processId: string; query?: ProcessLogFollowQuery }): Promise<ProcessLogsResponse> {
const client = await getSandboxAgentClient(c);
return await client.getProcessLogs(request.processId, request.query);
},
async stopProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const stopped = await client.stopProcess(request.processId, request.query);
broadcastProcessesUpdated(c);
return stopped;
},
async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const killed = await client.killProcess(request.processId, request.query);
broadcastProcessesUpdated(c);
return killed;
},
async deleteProcess(c: any, request: { processId: string }): Promise<void> {
const client = await getSandboxAgentClient(c);
await client.deleteProcess(request.processId);
broadcastProcessesUpdated(c);
},
async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
const at = Date.now();
const { config, driver } = getActorRuntimeContext();
if (c.state.providerId === "daytona") {
const daytona = driver.daytona.createClient({
apiUrl: config.providers.daytona.endpoint,
apiKey: config.providers.daytona.apiKey,
});
const sandbox = await daytona.getSandbox(c.state.sandboxId);
const state = String(sandbox.state ?? "unknown").toLowerCase();
return { providerId: c.state.providerId, sandboxId: c.state.sandboxId, state, at };
}
return {
providerId: c.state.providerId,
sandboxId: c.state.sandboxId,
state: "unknown",
at,
};
},
async ensure(c, command: EnsureSandboxCommand): Promise<void> {
const self = selfSandboxInstance(c);
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.ensure"), command, {
wait: true,
timeout: 60_000,
});
},
async updateHealth(c, command: HealthSandboxCommand): Promise<void> {
const self = selfSandboxInstance(c);
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.updateHealth"), command, {
wait: true,
timeout: 60_000,
});
},
async destroy(c): Promise<void> {
const self = selfSandboxInstance(c);
await self.send(
sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"),
{},
{
wait: true,
timeout: 60_000,
},
);
},
async createSession(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
const self = selfSandboxInstance(c);
return expectQueueResponse<CreateSessionResult>(
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.createSession"), command, {
wait: true,
timeout: 5 * 60_000,
}),
);
},
async listSessions(c: any, command?: ListSessionsCommand): Promise<{ items: SessionRecord[]; nextCursor?: string }> {
const persist = new SandboxInstancePersistDriver(c.db);
try {
const client = await getSandboxAgentClient(c);
const page = await client.listSessions({
cursor: command?.cursor,
limit: command?.limit,
});
return {
items: page.items,
nextCursor: page.nextCursor,
};
} catch (error) {
logActorWarning("sandbox-instance", "listSessions remote read failed; using persisted fallback", {
workspaceId: c.state.workspaceId,
providerId: c.state.providerId,
sandboxId: c.state.sandboxId,
error: resolveErrorMessage(error),
});
return await persist.listSessions({
cursor: command?.cursor,
limit: command?.limit,
});
}
},
async listSessionEvents(c: any, command: ListSessionEventsCommand): Promise<{ items: SessionEvent[]; nextCursor?: string }> {
const persist = new SandboxInstancePersistDriver(c.db);
return await persist.listEvents({
sessionId: command.sessionId,
cursor: command.cursor,
limit: command.limit,
});
},
async sendPrompt(c, command: SendPromptCommand): Promise<void> {
const self = selfSandboxInstance(c);
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.sendPrompt"), command, {
wait: true,
timeout: 5 * 60_000,
});
},
async cancelSession(c, command: SessionControlCommand): Promise<void> {
const self = selfSandboxInstance(c);
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.cancelSession"), command, {
wait: true,
timeout: 60_000,
});
},
async destroySession(c, command: SessionControlCommand): Promise<void> {
const self = selfSandboxInstance(c);
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroySession"), command, {
wait: true,
timeout: 60_000,
});
},
async sessionStatus(c, command: SessionStatusCommand): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return await derivePersistedSessionStatus(new SandboxInstancePersistDriver(c.db), command.sessionId);
},
},
run: workflow(runSandboxInstanceWorkflow),
});

View file

@ -0,0 +1,266 @@
import { and, asc, count, eq } from "drizzle-orm";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
import { sandboxSessionEvents, sandboxSessions } from "./db/schema.js";
const DEFAULT_MAX_SESSIONS = 1024;
const DEFAULT_MAX_EVENTS_PER_SESSION = 500;
const DEFAULT_LIST_LIMIT = 100;
function normalizeCap(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value) || (value ?? 0) < 1) {
return fallback;
}
return Math.floor(value as number);
}
function parseCursor(cursor: string | undefined): number {
if (!cursor) return 0;
const parsed = Number.parseInt(cursor, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return parsed;
}
export function resolveEventListOffset(params: { cursor?: string; total: number; limit: number }): number {
if (params.cursor != null) {
return parseCursor(params.cursor);
}
return Math.max(0, params.total - params.limit);
}
function safeStringify(value: unknown): string {
return JSON.stringify(value, (_key, item) => {
if (typeof item === "bigint") return item.toString();
return item;
});
}
function safeParseJson<T>(value: string | null | undefined, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
export interface SandboxInstancePersistDriverOptions {
maxSessions?: number;
maxEventsPerSession?: number;
}
export class SandboxInstancePersistDriver implements SessionPersistDriver {
private readonly maxSessions: number;
private readonly maxEventsPerSession: number;
constructor(
private readonly db: any,
options: SandboxInstancePersistDriverOptions = {},
) {
this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS);
this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
}
async getSession(id: string): Promise<SessionRecord | null> {
const row = await this.db
.select({
id: sandboxSessions.id,
agent: sandboxSessions.agent,
agentSessionId: sandboxSessions.agentSessionId,
lastConnectionId: sandboxSessions.lastConnectionId,
createdAt: sandboxSessions.createdAt,
destroyedAt: sandboxSessions.destroyedAt,
sessionInitJson: sandboxSessions.sessionInitJson,
})
.from(sandboxSessions)
.where(eq(sandboxSessions.id, id))
.get();
if (!row) return null;
return {
id: row.id,
agent: row.agent,
agentSessionId: row.agentSessionId,
lastConnectionId: row.lastConnectionId,
createdAt: row.createdAt,
destroyedAt: row.destroyedAt ?? undefined,
sessionInit: safeParseJson(row.sessionInitJson, undefined),
};
}
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
const offset = parseCursor(request.cursor);
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
const rows = await this.db
.select({
id: sandboxSessions.id,
agent: sandboxSessions.agent,
agentSessionId: sandboxSessions.agentSessionId,
lastConnectionId: sandboxSessions.lastConnectionId,
createdAt: sandboxSessions.createdAt,
destroyedAt: sandboxSessions.destroyedAt,
sessionInitJson: sandboxSessions.sessionInitJson,
})
.from(sandboxSessions)
.orderBy(asc(sandboxSessions.createdAt), asc(sandboxSessions.id))
.limit(limit)
.offset(offset)
.all();
const items = rows.map((row) => ({
id: row.id,
agent: row.agent,
agentSessionId: row.agentSessionId,
lastConnectionId: row.lastConnectionId,
createdAt: row.createdAt,
destroyedAt: row.destroyedAt ?? undefined,
sessionInit: safeParseJson(row.sessionInitJson, undefined),
}));
const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
const total = Number(totalRow?.c ?? 0);
const nextOffset = offset + items.length;
return {
items,
nextCursor: nextOffset < total ? String(nextOffset) : undefined,
};
}
async updateSession(session: SessionRecord): Promise<void> {
const now = Date.now();
await this.db
.insert(sandboxSessions)
.values({
id: session.id,
agent: session.agent,
agentSessionId: session.agentSessionId,
lastConnectionId: session.lastConnectionId,
createdAt: session.createdAt ?? now,
destroyedAt: session.destroyedAt ?? null,
sessionInitJson: session.sessionInit ? safeStringify(session.sessionInit) : null,
})
.onConflictDoUpdate({
target: sandboxSessions.id,
set: {
agent: session.agent,
agentSessionId: session.agentSessionId,
lastConnectionId: session.lastConnectionId,
createdAt: session.createdAt ?? now,
destroyedAt: session.destroyedAt ?? null,
sessionInitJson: session.sessionInit ? safeStringify(session.sessionInit) : null,
},
})
.run();
// Evict oldest sessions beyond cap.
const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
const total = Number(totalRow?.c ?? 0);
const overflow = total - this.maxSessions;
if (overflow <= 0) return;
const toRemove = await this.db
.select({ id: sandboxSessions.id })
.from(sandboxSessions)
.orderBy(asc(sandboxSessions.createdAt), asc(sandboxSessions.id))
.limit(overflow)
.all();
for (const row of toRemove) {
await this.db.delete(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, row.id)).run();
await this.db.delete(sandboxSessions).where(eq(sandboxSessions.id, row.id)).run();
}
}
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, request.sessionId)).get();
const total = Number(totalRow?.c ?? 0);
const offset = resolveEventListOffset({
cursor: request.cursor,
total,
limit,
});
const rows = await this.db
.select({
id: sandboxSessionEvents.id,
sessionId: sandboxSessionEvents.sessionId,
eventIndex: sandboxSessionEvents.eventIndex,
createdAt: sandboxSessionEvents.createdAt,
connectionId: sandboxSessionEvents.connectionId,
sender: sandboxSessionEvents.sender,
payloadJson: sandboxSessionEvents.payloadJson,
})
.from(sandboxSessionEvents)
.where(eq(sandboxSessionEvents.sessionId, request.sessionId))
.orderBy(asc(sandboxSessionEvents.eventIndex), asc(sandboxSessionEvents.id))
.limit(limit)
.offset(offset)
.all();
const items: SessionEvent[] = rows.map((row) => ({
id: row.id,
eventIndex: row.eventIndex,
sessionId: row.sessionId,
createdAt: row.createdAt,
connectionId: row.connectionId,
sender: row.sender as any,
payload: safeParseJson(row.payloadJson, null),
}));
const nextOffset = offset + items.length;
return {
items,
nextCursor: nextOffset < total ? String(nextOffset) : undefined,
};
}
async insertEvent(event: SessionEvent): Promise<void> {
await this.db
.insert(sandboxSessionEvents)
.values({
id: event.id,
sessionId: event.sessionId,
eventIndex: event.eventIndex,
createdAt: event.createdAt,
connectionId: event.connectionId,
sender: event.sender,
payloadJson: safeStringify(event.payload),
})
.onConflictDoUpdate({
target: sandboxSessionEvents.id,
set: {
sessionId: event.sessionId,
eventIndex: event.eventIndex,
createdAt: event.createdAt,
connectionId: event.connectionId,
sender: event.sender,
payloadJson: safeStringify(event.payload),
},
})
.run();
// Trim oldest events beyond cap.
const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, event.sessionId)).get();
const total = Number(totalRow?.c ?? 0);
const overflow = total - this.maxEventsPerSession;
if (overflow <= 0) return;
const toRemove = await this.db
.select({ id: sandboxSessionEvents.id })
.from(sandboxSessionEvents)
.where(eq(sandboxSessionEvents.sessionId, event.sessionId))
.orderBy(asc(sandboxSessionEvents.eventIndex), asc(sandboxSessionEvents.id))
.limit(overflow)
.all();
for (const row of toRemove) {
await this.db
.delete(sandboxSessionEvents)
.where(and(eq(sandboxSessionEvents.sessionId, event.sessionId), eq(sandboxSessionEvents.id, row.id)))
.run();
}
}
}

View file

@ -0,0 +1,108 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type { ProviderId } from "@sandbox-agent/foundry-shared";
import { getTask, getSandboxInstance, selfTaskStatusSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
export interface TaskStatusSyncInput {
workspaceId: string;
repoId: string;
taskId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
intervalMs: number;
}
interface SetIntervalCommand {
intervalMs: number;
}
interface TaskStatusSyncState extends PollingControlState {
workspaceId: string;
repoId: string;
taskId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
}
const CONTROL = {
start: "task.status_sync.control.start",
stop: "task.status_sync.control.stop",
setInterval: "task.status_sync.control.set_interval",
force: "task.status_sync.control.force",
} as const;
async function pollSessionStatus(c: { state: TaskStatusSyncState }): 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 = getTask(c, c.state.workspaceId, c.state.repoId, c.state.taskId);
await parent.syncWorkbenchSessionStatus({
sessionId: c.state.sessionId,
status: status.status,
at: Date.now(),
});
}
export const taskStatusSync = 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: TaskStatusSyncInput): TaskStatusSyncState => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
taskId: input.taskId,
providerId: input.providerId,
sandboxId: input.sandboxId,
sessionId: input.sessionId,
intervalMs: input.intervalMs,
running: true,
}),
actions: {
async start(c): Promise<void> {
const self = selfTaskStatusSync(c);
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
},
async stop(c): Promise<void> {
const self = selfTaskStatusSync(c);
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
},
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
const self = selfTaskStatusSync(c);
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
},
async force(c): Promise<void> {
const self = selfTaskStatusSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
},
},
run: workflow(async (ctx) => {
await runWorkflowPollingLoop<TaskStatusSyncState>(ctx, {
loopName: "task-status-sync-loop",
control: CONTROL,
onPoll: async (loopCtx) => {
try {
await pollSessionStatus(loopCtx);
} catch (error) {
logActorWarning("task-status-sync", "poll failed", {
error: resolveErrorMessage(error),
stack: resolveErrorStack(error),
});
}
},
});
}),
});

View file

@ -0,0 +1,5 @@
import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const taskDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/task/db/drizzle",
schema: "./src/actors/task/db/schema.ts",
});

View file

@ -0,0 +1,24 @@
CREATE TABLE `task` (
`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 `task_runtime` (
`id` integer PRIMARY KEY NOT NULL,
`sandbox_id` text,
`session_id` text,
`switch_target` text,
`status_message` text,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1,3 @@
ALTER TABLE `task` DROP COLUMN `auto_committed`;--> statement-breakpoint
ALTER TABLE `task` DROP COLUMN `pushed`;--> statement-breakpoint
ALTER TABLE `task` DROP COLUMN `needs_push`;

View file

@ -0,0 +1,38 @@
ALTER TABLE `task_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
ALTER TABLE `task_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
ALTER TABLE `task_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
CREATE TABLE `task_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 `task_runtime` ADD `active_cwd` text;
--> statement-breakpoint
INSERT INTO `task_sandboxes` (
`sandbox_id`,
`provider_id`,
`switch_target`,
`cwd`,
`status_message`,
`created_at`,
`updated_at`
)
SELECT
r.`active_sandbox_id`,
(SELECT h.`provider_id` FROM `task` h WHERE h.`id` = 1),
r.`active_switch_target`,
r.`active_cwd`,
r.`status_message`,
COALESCE((SELECT h.`created_at` FROM `task` h WHERE h.`id` = 1), r.`updated_at`),
r.`updated_at`
FROM `task_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;

View file

@ -0,0 +1,48 @@
-- Allow tasks 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 `task__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 `task__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 `task`;
DROP TABLE `task`;
ALTER TABLE `task__new` RENAME TO `task`;
PRAGMA foreign_keys=on;

View file

@ -0,0 +1,57 @@
-- 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 `task__new`;
--> statement-breakpoint
CREATE TABLE `task__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 `task__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 `task`;
--> statement-breakpoint
DROP TABLE `task`;
--> statement-breakpoint
ALTER TABLE `task__new` RENAME TO `task`;
--> statement-breakpoint
PRAGMA foreign_keys=on;

View file

@ -0,0 +1 @@
ALTER TABLE `task_sandboxes` ADD `sandbox_actor_id` text;

View file

@ -0,0 +1,14 @@
CREATE TABLE `task_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
);

View file

@ -0,0 +1,176 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9b004d3b-0722-4bb5-a410-d47635db7df3",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"task": {
"name": "task",
"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": {}
},
"task_runtime": {
"name": "task_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": {}
}
}

View file

@ -0,0 +1,152 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0fca0f14-69df-4fca-bc52-29e902247909",
"prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3",
"tables": {
"task": {
"name": "task",
"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": {}
},
"task_runtime": {
"name": "task_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": {}
}
}

View file

@ -0,0 +1,222 @@
{
"version": "6",
"dialect": "sqlite",
"id": "72cef919-e545-48be-a7c0-7ac74cfcf9e6",
"prevId": "0fca0f14-69df-4fca-bc52-29e902247909",
"tables": {
"task": {
"name": "task",
"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": {}
},
"task_runtime": {
"name": "task_runtime",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"active_sandbox_id": {
"name": "active_sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_switch_target": {
"name": "active_switch_target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_cwd": {
"name": "active_cwd",
"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": {}
},
"task_sandboxes": {
"name": "task_sandboxes",
"columns": {
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"switch_target": {
"name": "switch_target",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"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": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"task_runtime\".\"sandbox_id\"": "\"task_runtime\".\"active_sandbox_id\"",
"\"task_runtime\".\"session_id\"": "\"task_runtime\".\"active_session_id\"",
"\"task_runtime\".\"switch_target\"": "\"task_runtime\".\"active_switch_target\""
}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,48 @@
{
"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
}
]
}

View file

@ -0,0 +1,245 @@
// 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 \`task\` (
\`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 \`task_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 \`task\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
ALTER TABLE \`task\` DROP COLUMN \`pushed\`;--> statement-breakpoint
ALTER TABLE \`task\` DROP COLUMN \`needs_push\`;`,
m0002: `ALTER TABLE \`task_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
ALTER TABLE \`task_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
ALTER TABLE \`task_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
CREATE TABLE \`task_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 \`task_runtime\` ADD \`active_cwd\` text;
--> statement-breakpoint
INSERT INTO \`task_sandboxes\` (
\`sandbox_id\`,
\`provider_id\`,
\`switch_target\`,
\`cwd\`,
\`status_message\`,
\`created_at\`,
\`updated_at\`
)
SELECT
r.\`active_sandbox_id\`,
(SELECT h.\`provider_id\` FROM \`task\` h WHERE h.\`id\` = 1),
r.\`active_switch_target\`,
r.\`active_cwd\`,
r.\`status_message\`,
COALESCE((SELECT h.\`created_at\` FROM \`task\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
r.\`updated_at\`
FROM \`task_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 tasks 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 \`task__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 \`task__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 \`task\`;
DROP TABLE \`task\`;
ALTER TABLE \`task__new\` RENAME TO \`task\`;
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 \`task__new\`;
--> statement-breakpoint
CREATE TABLE \`task__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 \`task__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 \`task\`;
--> statement-breakpoint
DROP TABLE \`task\`;
--> statement-breakpoint
ALTER TABLE \`task__new\` RENAME TO \`task\`;
--> statement-breakpoint
PRAGMA foreign_keys=on;
`,
m0005: `ALTER TABLE \`task_sandboxes\` ADD \`sandbox_actor_id\` text;`,
m0006: `CREATE TABLE \`task_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,
};

View file

@ -0,0 +1,51 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per task actor instance, so these tables only ever store one row (id=1).
export const task = sqliteTable("task", {
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 taskRuntime = sqliteTable("task_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 taskSandboxes = sqliteTable("task_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 taskWorkbenchSessions = sqliteTable("task_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(),
});

View file

@ -0,0 +1,385 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
AgentType,
TaskRecord,
TaskWorkbenchChangeModelInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchUpdateDraftInput,
ProviderId,
} from "@sandbox-agent/foundry-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfTask } from "../handles.js";
import { taskDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getWorkbenchTask,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
syncWorkbenchSessionStatus,
setWorkbenchSessionUnread,
stopWorkbenchSession,
updateWorkbenchDraft,
} from "./workbench.js";
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
export interface TaskInput {
workspaceId: string;
repoId: string;
taskId: string;
repoRemote: string;
repoLocalPath: string;
branchName: string | null;
title: string | null;
task: string;
providerId: ProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
initialPrompt: string | null;
}
interface InitializeCommand {
providerId?: ProviderId;
}
interface TaskActionCommand {
reason?: string;
}
interface TaskTabCommand {
tabId: string;
}
interface TaskStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface TaskWorkbenchValueCommand {
value: string;
}
interface TaskWorkbenchSessionTitleCommand {
sessionId: string;
title: string;
}
interface TaskWorkbenchSessionUnreadCommand {
sessionId: string;
unread: boolean;
}
interface TaskWorkbenchUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface TaskWorkbenchChangeModelCommand {
sessionId: string;
model: string;
}
interface TaskWorkbenchSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface TaskWorkbenchCreateSessionCommand {
model?: string;
}
interface TaskWorkbenchSessionCommand {
sessionId: string;
}
export const task = actor({
db: taskDb,
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000,
},
createState: (_c, input: TaskInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
taskId: input.taskId,
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,
initialPrompt: input.initialPrompt,
initialized: false,
previousStatus: null as string | null,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 60_000,
});
return expectQueueResponse<TaskRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: true,
timeout: 30 * 60_000,
});
return { ok: true };
},
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
wait: true,
timeout: 20_000,
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.switch"),
{},
{
wait: true,
timeout: 20_000,
},
);
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: true,
timeout: 180_000,
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: true,
timeout: 30_000,
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: true,
timeout: 30_000,
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
void self
.send(taskWorkflowQueueName("task.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?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: true,
timeout: 60_000,
});
},
async get(c): Promise<TaskRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
},
async getWorkbench(c) {
return await getWorkbenchTask(c);
},
async markWorkbenchUnread(c): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.mark_unread"),
{},
{
wait: true,
timeout: 20_000,
},
);
},
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: true,
timeout: 20_000,
});
},
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: true,
timeout: 5 * 60_000,
});
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
return expectQueueResponse<{ tabId: string }>(result);
},
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_session"),
{ sessionId: input.tabId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
{ sessionId: input.tabId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.update_draft"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchUpdateDraftCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.change_model"),
{ sessionId: input.tabId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.send_message"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
},
);
},
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
wait: true,
timeout: 5 * 60_000,
});
},
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, {
wait: true,
timeout: 20_000,
});
},
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
wait: true,
timeout: 5 * 60_000,
});
},
async publishWorkbenchPr(c): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.publish_pr"),
{},
{
wait: true,
timeout: 10 * 60_000,
},
);
},
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
wait: true,
timeout: 5 * 60_000,
});
},
},
run: workflow(runTaskWorkflow),
});
export { TASK_QUEUE_NAMES };

View file

@ -0,0 +1,818 @@
// @ts-nocheck
import { basename } from "node:path";
import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js";
import { task as taskTable, taskRuntime, taskWorkbenchSessions } 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 task_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(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.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(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.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(taskWorkbenchSessions)
.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(taskWorkbenchSessions)
.set({
...values,
updatedAt: Date.now(),
})
.where(eq(taskWorkbenchSessions.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 getWorkbenchTask(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.taskId,
repoId: c.state.repoId,
title: record.title ?? "New Task",
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 renameWorkbenchTask(c: any, value: string): Promise<void> {
const nextTitle = value.trim();
if (!nextTitle) {
throw new Error("task title is required");
}
await c.db
.update(taskTable)
.set({
title: nextTitle,
updatedAt: Date.now(),
})
.where(eq(taskTable.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 task 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(taskTable)
.set({
branchName: nextBranch,
updatedAt: Date.now(),
})
.where(eq(taskTable.id, 1))
.run();
c.state.branchName = nextBranch;
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
await project.registerTaskBranch({
taskId: c.state.taskId,
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(taskRuntime)
.set({
activeSessionId: sessionId,
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, 1))
.run();
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, sessionId, {
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
taskId: c.state.taskId,
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(taskTable)
.set({
status: mappedStatus,
updatedAt: at,
})
.where(eq(taskTable.id, 1))
.run();
changed = true;
}
const statusMessage = `session:${status}`;
if (record.statusMessage !== statusMessage) {
await c.db
.update(taskRuntime)
.set({
statusMessage,
updatedAt: at,
})
.where(eq(taskRuntime.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(taskRuntime)
.set({
activeSessionId: null,
updatedAt: Date.now(),
})
.where(eq(taskRuntime.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(taskTable)
.set({
prSubmitted: 1,
updatedAt: Date.now(),
})
.where(eq(taskTable.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);
}

View file

@ -0,0 +1,175 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateTaskStatusSync } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { task as taskTable, taskRuntime } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } 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, "task.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: taskRuntime.activeSwitchTarget }).from(taskRuntime).where(eq(taskRuntime.id, TASK_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: "task.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(taskRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_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 setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
const record = await getCurrentRecord(loopCtx);
if (record.activeSandboxId && record.activeSessionId) {
try {
const sync = await getOrCreateTaskStatusSync(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.taskId,
record.activeSandboxId,
record.activeSessionId,
{
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
providerId: record.providerId,
sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId,
intervalMs: 2_000,
},
);
await withTimeout(sync.stop(), 15_000, "task status sync stop");
} catch (error) {
logActorWarning("task.commands", "failed to stop status sync during archive", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId,
error: resolveErrorMessage(error),
});
}
}
if (record.activeSandboxId) {
await setTaskState(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 taskId = loopCtx.state.taskId;
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("task.commands", "failed to release sandbox during archive", {
workspaceId,
repoId,
taskId,
sandboxId,
error: resolveErrorMessage(error),
});
});
}
const db = loopCtx.db;
await setTaskState(loopCtx, "archive_finalize", "finalizing archive");
await db.update(taskTable).set({ status: "archived", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
await db.update(taskRuntime).set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
await setTaskState(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 setTaskState(loopCtx, "kill_finalize", "finalizing kill");
const db = loopCtx.db;
await db.update(taskTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
await db.update(taskRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
await appendHistory(loopCtx, "task.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));
}

View file

@ -0,0 +1,181 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
import { getOrCreateWorkspace } from "../../handles.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { historyKey } from "../../keys.js";
export const TASK_ROW_ID = 1;
export function collectErrorMessages(error: unknown): string[] {
if (error == null) {
return [];
}
const out: string[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current != null && !seen.has(current)) {
seen.add(current);
if (current instanceof Error) {
const message = current.message?.trim();
if (message) {
out.push(message);
}
current = (current as { cause?: unknown }).cause;
continue;
}
if (typeof current === "string") {
const message = current.trim();
if (message) {
out.push(message);
}
break;
}
break;
}
return out.filter((msg, index) => out.indexOf(msg) === index);
}
export function resolveErrorDetail(error: unknown): string {
const messages = collectErrorMessages(error);
if (messages.length === 0) {
return String(error);
}
const nonWorkflowWrapper = messages.find((msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg));
return nonWorkflowWrapper ?? messages[0]!;
}
export function buildAgentPrompt(task: string): string {
return task.trim();
}
export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: string): Promise<void> {
const now = Date.now();
const db = ctx.db;
await db.update(taskTable).set({ status, updatedAt: now }).where(eq(taskTable.id, TASK_ROW_ID)).run();
if (statusMessage != null) {
await db
.insert(taskRuntime)
.values({
id: TASK_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.id,
set: {
statusMessage,
updatedAt: now,
},
})
.run();
}
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
}
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
const db = ctx.db;
const row = await db
.select({
branchName: taskTable.branchName,
title: taskTable.title,
task: taskTable.task,
providerId: taskTable.providerId,
status: taskTable.status,
statusMessage: taskRuntime.statusMessage,
activeSandboxId: taskRuntime.activeSandboxId,
activeSessionId: taskRuntime.activeSessionId,
agentType: taskTable.agentType,
prSubmitted: taskTable.prSubmitted,
createdAt: taskTable.createdAt,
updatedAt: taskTable.updatedAt,
})
.from(taskTable)
.leftJoin(taskRuntime, eq(taskTable.id, taskRuntime.id))
.where(eq(taskTable.id, TASK_ROW_ID))
.get();
if (!row) {
throw new Error(`Task not found: ${ctx.state.taskId}`);
}
const sandboxes = await db
.select({
sandboxId: taskSandboxes.sandboxId,
providerId: taskSandboxes.providerId,
sandboxActorId: taskSandboxes.sandboxActorId,
switchTarget: taskSandboxes.switchTarget,
cwd: taskSandboxes.cwd,
createdAt: taskSandboxes.createdAt,
updatedAt: taskSandboxes.updatedAt,
})
.from(taskSandboxes)
.all();
return {
workspaceId: ctx.state.workspaceId,
repoId: ctx.state.repoId,
repoRemote: ctx.state.repoRemote,
taskId: ctx.state.taskId,
branchName: row.branchName,
title: row.title,
task: row.task,
providerId: row.providerId,
status: row.status,
statusMessage: row.statusMessage ?? null,
activeSandboxId: row.activeSandboxId ?? null,
activeSessionId: row.activeSessionId ?? null,
sandboxes: sandboxes.map((sb) => ({
sandboxId: sb.sandboxId,
providerId: sb.providerId,
sandboxActorId: sb.sandboxActorId ?? null,
switchTarget: sb.switchTarget,
cwd: sb.cwd ?? null,
createdAt: sb.createdAt,
updatedAt: sb.updatedAt,
})),
agentType: row.agentType ?? null,
prSubmitted: Boolean(row.prSubmitted),
diffStat: null,
hasUnpushed: null,
conflictsWithMain: null,
parentBranch: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} as TaskRecord;
}
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const client = ctx.client();
const history = await client.history.getOrCreate(historyKey(ctx.state.workspaceId, ctx.state.repoId), {
createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId },
});
await history.append({
kind,
taskId: ctx.state.taskId,
branchName: ctx.state.branchName,
payload,
});
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
}

View file

@ -0,0 +1,270 @@
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,
initExposeSandboxActivity,
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 { TASK_QUEUE_NAMES } from "./queue.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
setWorkbenchSessionUnread,
stopWorkbenchSession,
syncWorkbenchSessionStatus,
updateWorkbenchDraft,
} from "../workbench.js";
export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js";
type TaskQueueName = (typeof TASK_QUEUE_NAMES)[number];
type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
"task.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("task.workflow", "initialize completion failed", {
error: resolveErrorMessage(error),
});
}
},
"task.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),
});
await loopCtx.step("init-expose-sandbox", async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady));
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 });
}
},
"task.command.attach": async (loopCtx, msg) => {
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
},
"task.command.switch": async (loopCtx, msg) => {
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
},
"task.command.push": async (loopCtx, msg) => {
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
},
"task.command.sync": async (loopCtx, msg) => {
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync"));
},
"task.command.merge": async (loopCtx, msg) => {
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge"));
},
"task.command.archive": async (loopCtx, msg) => {
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
},
"task.command.kill": async (loopCtx, msg) => {
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
},
"task.command.get": async (loopCtx, msg) => {
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
},
"task.command.workbench.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
await msg.complete({ ok: true });
},
"task.command.workbench.rename_task": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value));
await msg.complete({ ok: true });
},
"task.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 });
},
"task.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);
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 });
},
"task.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 runTaskWorkflow(ctx: any): Promise<void> {
await ctx.loop("task-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-command", {
names: [...TASK_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
const handler = commandHandlers[msg.name as TaskQueueName];
if (handler) {
await handler(loopCtx, msg);
}
return Loop.continue(undefined);
});
}

View file

@ -0,0 +1,607 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
import { taskWorkflowQueueName } 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: "task.init",
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
...(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(taskTable)
.values({
id: TASK_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: taskTable.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(taskRuntime)
.values({
id: TASK_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
updatedAt: now,
},
})
.run();
} catch (error) {
const detail = resolveErrorMessage(error);
throw new Error(`task init bootstrap db failed: ${detail}`);
}
}
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
const self = selfTask(loopCtx);
void self
.send(taskWorkflowQueueName("task.command.provision"), body, {
wait: false,
})
.catch((error: unknown) => {
logActorWarning("task.init", "background provision command failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
});
});
}
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
await setTaskState(loopCtx, "init_ensure_name", "determining title and branch");
const existing = await loopCtx.db
.select({
branchName: taskTable.branchName,
title: taskTable.title,
})
.from(taskTable)
.where(eq(taskTable.id, TASK_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("task.init", "fetch before naming failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
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,
taskBranches: reservedBranches,
});
const now = Date.now();
await loopCtx.db
.update(taskTable)
.set({
branchName: resolved.branchName,
title: resolved.title,
updatedAt: now,
})
.where(eq(taskTable.id, TASK_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(taskRuntime)
.set({
statusMessage: "provisioning",
updatedAt: now,
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await project.registerTaskBranch({
taskId: loopCtx.state.taskId,
branchName: resolved.branchName,
});
await appendHistory(loopCtx, "task.named", {
title: resolved.title,
branchName: resolved.branchName,
});
}
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
await setTaskState(loopCtx, "init_assert_name", "validating naming");
if (!loopCtx.state.branchName) {
throw new Error("task branchName is not initialized");
}
}
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
await setTaskState(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: taskRuntime.activeSandboxId }).from(taskRuntime).where(eq(taskRuntime.id, TASK_ROW_ID)).get();
const existing = await loopCtx.db
.select({ sandboxId: taskSandboxes.sandboxId })
.from(taskSandboxes)
.where(eq(taskSandboxes.providerId, providerId))
.orderBy(desc(taskSandboxes.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("task.init", "resume sandbox failed; creating a new sandbox", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
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,
taskId: loopCtx.state.taskId,
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 setTaskState(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 setTaskState(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 setTaskState(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: typeof loopCtx.state.initialPrompt === "string" ? loopCtx.state.initialPrompt : buildAgentPrompt(loopCtx.state.task),
cwd,
agent: (loopCtx.state.agentType ?? config.default_agent) as any,
});
}
export async function initExposeSandboxActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady?: { actorId?: string | null }): Promise<void> {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const now = Date.now();
const db = loopCtx.db;
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
.insert(taskSandboxes)
.values({
sandboxId: sandbox.sandboxId,
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage: "sandbox ready",
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskSandboxes.sandboxId,
set: {
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage: "sandbox ready",
updatedAt: now,
},
})
.run();
await db
.update(taskRuntime)
.set({
activeSandboxId: sandbox.sandboxId,
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage: "sandbox ready",
updatedAt: now,
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
}
export async function initWriteDbActivity(
loopCtx: any,
body: any,
sandbox: any,
session: any,
sandboxInstanceReady?: { actorId?: string | null },
): Promise<void> {
await setTaskState(loopCtx, "init_write_db", "persisting task 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(taskTable)
.set({
providerId,
status: sessionHealthy ? "running" : "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now,
})
.where(eq(taskTable.id, TASK_ROW_ID))
.run();
await db
.insert(taskSandboxes)
.values({
sandboxId: sandbox.sandboxId,
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskSandboxes.sandboxId,
set: {
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage,
updatedAt: now,
},
})
.run();
await db
.insert(taskRuntime)
.values({
id: TASK_ROW_ID,
activeSandboxId: sandbox.sandboxId,
activeSessionId,
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.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 setTaskState(loopCtx, "init_start_status_sync", "starting session status sync");
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sync = await getOrCreateTaskStatusSync(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.taskId, sandbox.sandboxId, sessionId, {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
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 setTaskState(loopCtx, "init_complete", "task initialized");
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
await history.append({
kind: "task.initialized",
taskId: loopCtx.state.taskId,
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 setTaskState(loopCtx, "error", detail);
await appendHistory(loopCtx, "task.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(taskTable)
.values({
id: TASK_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: taskTable.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(taskRuntime)
.values({
id: TASK_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
updatedAt: now,
},
})
.run();
await appendHistory(loopCtx, "task.error", {
detail,
messages,
});
}

View file

@ -0,0 +1,84 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { taskRuntime, taskSandboxes } from "../db/schema.js";
import { TASK_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: task 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(taskRuntime)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(taskSandboxes.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(taskRuntime)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
await appendHistory(loopCtx, options.historyKind ?? "task.push", {
reason: options.reason ?? null,
branchName,
sandboxId: activeSandboxId,
});
}

View file

@ -0,0 +1,31 @@
export const TASK_QUEUE_NAMES = [
"task.command.initialize",
"task.command.provision",
"task.command.attach",
"task.command.switch",
"task.command.push",
"task.command.sync",
"task.command.merge",
"task.command.archive",
"task.command.kill",
"task.command.get",
"task.command.workbench.mark_unread",
"task.command.workbench.rename_task",
"task.command.workbench.rename_branch",
"task.command.workbench.create_session",
"task.command.workbench.rename_session",
"task.command.workbench.set_session_unread",
"task.command.workbench.update_draft",
"task.command.workbench.change_model",
"task.command.workbench.send_message",
"task.command.workbench.stop_session",
"task.command.workbench.sync_session_status",
"task.command.workbench.close_session",
"task.command.workbench.publish_pr",
"task.command.workbench.revert_file",
"task.status_sync.result",
] as const;
export function taskWorkflowQueueName(name: string): string {
return name;
}

View file

@ -0,0 +1,143 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { TASK_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: taskRuntime.activeSandboxId,
activeSessionId: taskRuntime.activeSessionId,
})
.from(taskRuntime)
.where(eq(taskRuntime.id, TASK_ROW_ID))
.get();
const isActive = runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
if (isActive) {
await db.update(taskTable).set({ status: newStatus, updatedAt: body.at }).where(eq(taskTable.id, TASK_ROW_ID)).run();
await db
.update(taskRuntime)
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
}
await db
.update(taskSandboxes)
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
.where(eq(taskSandboxes.sandboxId, body.sandboxId))
.run();
await appendHistory(loopCtx, "task.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: taskTable.prSubmitted }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get();
if (self && self.prSubmitted) return;
try {
await driver.git.fetch(loopCtx.state.repoLocalPath);
} catch (error) {
logActorWarning("task.status-sync", "fetch before PR submit failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
});
}
if (!loopCtx.state.branchName || !loopCtx.state.title) {
throw new Error("cannot submit PR before task has a branch and title");
}
try {
await pushActiveBranchActivity(loopCtx, {
reason: "auto_submit_idle",
historyKind: "task.push.auto",
});
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title);
await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
await appendHistory(loopCtx, "task.step", {
step: "pr_submit",
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
prUrl: pr.url,
prNumber: pr.number,
});
await appendHistory(loopCtx, "task.pr_created", {
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
prUrl: pr.url,
prNumber: pr.number,
});
} catch (error) {
const detail = resolveErrorDetail(error);
await db
.update(taskRuntime)
.set({
statusMessage: `pr submit failed: ${detail}`,
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await appendHistory(loopCtx, "task.pr_create_failed", {
taskId: loopCtx.state.taskId,
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);
}
}

View file

@ -0,0 +1,672 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
AddRepoInput,
CreateTaskInput,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ListTasksInput,
ProviderId,
RepoOverview,
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
WorkspaceUseInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
import { agentTypeForModel } from "../task/workbench.js";
import { expectQueueResponse } from "../../services/queue.js";
import { workspaceAppActions } from "./app-shell.js";
interface WorkspaceState {
workspaceId: string;
}
interface RefreshProviderProfilesCommand {
providerId?: ProviderId;
}
interface GetTaskInput {
workspaceId: string;
taskId: string;
}
interface TaskProxyActionInput extends GetTaskInput {
reason?: string;
}
interface RepoOverviewInput {
workspaceId: string;
repoId: string;
}
const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createTask", "workspace.command.refreshProviderProfiles"] as const;
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number];
export { WORKSPACE_QUEUE_NAMES };
export function workspaceWorkflowQueueName(name: WorkspaceQueueName): WorkspaceQueueName {
return name;
}
function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): void {
if (workspaceId !== c.state.workspaceId) {
throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} command=${workspaceId}`);
}
}
async function resolveRepoId(c: any, taskId: string): Promise<string> {
const row = await c.db.select({ repoId: taskLookup.repoId }).from(taskLookup).where(eq(taskLookup.taskId, taskId)).get();
if (!row) {
throw new Error(`Unknown task: ${taskId} (not in lookup)`);
}
return row.repoId;
}
async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise<void> {
await c.db
.insert(taskLookup)
.values({
taskId,
repoId,
})
.onConflictDoUpdate({
target: taskLookup.taskId,
set: { repoId },
})
.run();
}
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
const all: TaskSummary[] = [];
for (const row of repoRows) {
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const snapshot = await project.listTaskSummaries({ includeArchived: true });
all.push(...snapshot);
} catch (error) {
logActorWarning("workspace", "failed collecting tasks for repo", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
all.sort((a, b) => b.updatedAt - a.updatedAt);
return all;
}
function repoLabelFromRemote(remoteUrl: string): string {
try {
const url = new URL(remoteUrl.startsWith("http") ? remoteUrl : `https://${remoteUrl}`);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`;
}
} catch {
// ignore
}
return remoteUrl;
}
async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const tasks: Array<any> = [];
const projects: Array<any> = [];
for (const row of repoRows) {
const projectTasks: Array<any> = [];
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const summaries = await project.listTaskSummaries({ includeArchived: true });
for (const summary of summaries) {
try {
await upsertTaskLookupRow(c, summary.taskId, row.repoId);
const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId);
const snapshot = await task.getWorkbench({});
tasks.push(snapshot);
projectTasks.push(snapshot);
} catch (error) {
logActorWarning("workspace", "failed collecting workbench task", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
taskId: summary.taskId,
error: resolveErrorMessage(error),
});
}
}
if (projectTasks.length > 0) {
projects.push({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl),
updatedAtMs: projectTasks[0]?.updatedAtMs ?? row.updatedAt,
tasks: projectTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
});
}
} catch (error) {
logActorWarning("workspace", "failed collecting workbench repo snapshot", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
return {
workspaceId: c.state.workspaceId,
repos: repoRows.map((row) => ({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl),
})),
projects,
tasks,
};
}
async function requireWorkbenchTask(c: any, taskId: string) {
const repoId = await resolveRepoId(c, taskId);
return getTask(c, c.state.workspaceId, repoId, taskId);
}
async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord> {
assertWorkspace(c, input.workspaceId);
const remoteUrl = normalizeRemoteUrl(input.remoteUrl);
if (!remoteUrl) {
throw new Error("remoteUrl is required");
}
const { driver } = getActorRuntimeContext();
await driver.git.validateRemote(remoteUrl);
const repoId = repoIdFromRemote(remoteUrl);
const now = Date.now();
await c.db
.insert(repos)
.values({
repoId,
remoteUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl,
updatedAt: now,
},
})
.run();
await workspaceActions.notifyWorkbenchUpdated(c);
return {
workspaceId: c.state.workspaceId,
repoId,
remoteUrl,
createdAt: now,
updatedAt: now,
};
}
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertWorkspace(c, input.workspaceId);
const { providers } = getActorRuntimeContext();
const providerId = input.providerId ?? providers.defaultProviderId();
const repoId = input.repoId;
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
const remoteUrl = repoRow.remoteUrl;
await c.db
.insert(providerProfiles)
.values({
providerId,
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: providerProfiles.providerId,
set: {
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
},
})
.run();
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
await project.ensure({ remoteUrl });
const created = await project.createTask({
task: input.task,
providerId,
agentType: input.agentType ?? null,
explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null,
onBranch: input.onBranch ?? null,
});
await c.db
.insert(taskLookup)
.values({
taskId: created.taskId,
repoId,
})
.onConflictDoUpdate({
target: taskLookup.taskId,
set: { repoId },
})
.run();
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
await task.provision({ providerId });
await workspaceActions.notifyWorkbenchUpdated(c);
return created;
}
async function refreshProviderProfilesMutation(c: any, command?: RefreshProviderProfilesCommand): Promise<void> {
const body = command ?? {};
const { providers } = getActorRuntimeContext();
const providerIds: ProviderId[] = body.providerId ? [body.providerId] : providers.availableProviderIds();
for (const providerId of providerIds) {
await c.db
.insert(providerProfiles)
.values({
providerId,
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: providerProfiles.providerId,
set: {
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
},
})
.run();
}
}
export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
await ctx.loop("workspace-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-workspace-command", {
names: [...WORKSPACE_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "workspace.command.addRepo") {
const result = await loopCtx.step({
name: "workspace-add-repo",
timeout: 60_000,
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "workspace.command.createTask") {
const result = await loopCtx.step({
name: "workspace-create-task",
timeout: 12 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "workspace.command.refreshProviderProfiles") {
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
);
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}
export const workspaceActions = {
...workspaceAppActions,
async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> {
assertWorkspace(c, input.workspaceId);
return { workspaceId: c.state.workspaceId };
},
async addRepo(c: any, input: AddRepoInput): Promise<RepoRecord> {
const self = selfWorkspace(c);
return expectQueueResponse<RepoRecord>(
await self.send(workspaceWorkflowQueueName("workspace.command.addRepo"), input, {
wait: true,
timeout: 60_000,
}),
);
},
async listRepos(c: any, input: WorkspaceUseInput): Promise<RepoRecord[]> {
assertWorkspace(c, input.workspaceId);
const rows = await c.db
.select({
repoId: repos.repoId,
remoteUrl: repos.remoteUrl,
createdAt: repos.createdAt,
updatedAt: repos.updatedAt,
})
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
return rows.map((row) => ({
workspaceId: c.state.workspaceId,
repoId: row.repoId,
remoteUrl: row.remoteUrl,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
},
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfWorkspace(c);
return expectQueueResponse<TaskRecord>(
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
wait: true,
timeout: 12 * 60_000,
}),
);
},
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
assertWorkspace(c, input.workspaceId);
const { driver } = getActorRuntimeContext();
await driver.github.starRepository(SANDBOX_AGENT_REPO);
return {
repo: SANDBOX_AGENT_REPO,
starredAt: Date.now(),
};
},
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<TaskWorkbenchSnapshot> {
assertWorkspace(c, input.workspaceId);
return await buildWorkbenchSnapshot(c);
},
async notifyWorkbenchUpdated(c: any): Promise<void> {
c.broadcast("workbenchUpdated", { at: Date.now() });
},
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> {
const created = await workspaceActions.createTask(c, {
workspaceId: c.state.workspaceId,
repoId: input.repoId,
task: input.task,
...(input.title ? { explicitTitle: input.title } : {}),
...(input.branch ? { explicitBranchName: input.branch } : {}),
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
});
return { taskId: created.taskId };
},
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.markWorkbenchUnread({});
},
async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchTask(input);
},
async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchBranch(input);
},
async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
const task = await requireWorkbenchTask(c, input.taskId);
return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
},
async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.updateWorkbenchDraft(input);
},
async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.changeWorkbenchModel(input);
},
async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.sendWorkbenchMessage(input);
},
async stopWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.stopWorkbenchSession(input);
},
async closeWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.closeWorkbenchSession(input);
},
async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.publishWorkbenchPr({});
},
async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.revertWorkbenchFile(input);
},
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
assertWorkspace(c, input.workspaceId);
if (input.repoId) {
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
return await project.listTaskSummaries({ includeArchived: true });
}
return await collectAllTaskSummaries(c);
},
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
assertWorkspace(c, input.workspaceId);
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await project.ensure({ remoteUrl: repoRow.remoteUrl });
return await project.getRepoOverview({});
},
async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> {
assertWorkspace(c, input.workspaceId);
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await project.ensure({ remoteUrl: repoRow.remoteUrl });
return await project.runRepoStackAction({
action: input.action,
branchName: input.branchName,
parentBranch: input.parentBranch,
});
},
async switchTask(c: any, taskId: string): Promise<SwitchResult> {
const repoId = await resolveRepoId(c, taskId);
const h = getTask(c, c.state.workspaceId, repoId, taskId);
const record = await h.get();
const switched = await h.switch();
return {
workspaceId: c.state.workspaceId,
taskId,
providerId: record.providerId,
switchTarget: switched.switchTarget,
};
},
async refreshProviderProfiles(c: any, command?: RefreshProviderProfilesCommand): Promise<void> {
const self = selfWorkspace(c);
await self.send(workspaceWorkflowQueueName("workspace.command.refreshProviderProfiles"), command ?? {}, {
wait: true,
timeout: 60_000,
});
},
async history(c: any, input: HistoryQueryInput): Promise<HistoryEvent[]> {
assertWorkspace(c, input.workspaceId);
const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all();
const allEvents: HistoryEvent[] = [];
for (const row of repoRows) {
try {
const hist = await getOrCreateHistory(c, c.state.workspaceId, row.repoId);
const items = await hist.list({
branch: input.branch,
taskId: input.taskId,
limit,
});
allEvents.push(...items);
} catch (error) {
logActorWarning("workspace", "history lookup failed for repo", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
allEvents.sort((a, b) => b.createdAt - a.createdAt);
return allEvents.slice(0, limit);
},
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, repoRow.remoteUrl);
return await project.getTaskEnriched({ taskId: input.taskId });
},
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
return await h.attach({ reason: input.reason });
},
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
await h.push({ reason: input.reason });
},
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
await h.sync({ reason: input.reason });
},
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
await h.merge({ reason: input.reason });
},
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
await h.archive({ reason: input.reason });
},
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
await h.kill({ reason: input.reason });
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const workspaceDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/workspace/db/drizzle",
schema: "./src/actors/workspace/db/schema.ts",
});

View file

@ -0,0 +1,5 @@
CREATE TABLE `provider_profiles` (
`provider_id` text PRIMARY KEY NOT NULL,
`profile_json` text NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1,6 @@
CREATE TABLE `repos` (
`repo_id` text PRIMARY KEY NOT NULL,
`remote_url` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1,4 @@
CREATE TABLE `task_lookup` (
`task_id` text PRIMARY KEY NOT NULL,
`repo_id` text NOT NULL
);

View file

@ -0,0 +1,49 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a85809c0-65c2-4f99-92ed-34357c9f83d7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"provider_profiles": {
"name": "provider_profiles",
"columns": {
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"profile_json": {
"name": "profile_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": {}
}
}

View file

@ -0,0 +1,87 @@
{
"version": "6",
"dialect": "sqlite",
"id": "450e2fdf-6349-482f-8a68-5bc0f0a9718a",
"prevId": "a85809c0-65c2-4f99-92ed-34357c9f83d7",
"tables": {
"provider_profiles": {
"name": "provider_profiles",
"columns": {
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"profile_json": {
"name": "profile_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repos": {
"name": "repos",
"columns": {
"repo_id": {
"name": "repo_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"remote_url": {
"name": "remote_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"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": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1770924376525,
"tag": "0000_rare_iron_man",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1770947252912,
"tag": "0001_sleepy_lady_deathstrike",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1772668800000,
"tag": "0002_tiny_silver_surfer",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,189 @@
// 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: 1770924376525,
tag: "0000_rare_iron_man",
breakpoints: true,
},
{
idx: 1,
when: 1770947252912,
tag: "0001_sleepy_lady_deathstrike",
breakpoints: true,
},
{
idx: 2,
when: 1772668800000,
tag: "0002_tiny_silver_surfer",
breakpoints: true,
},
{
idx: 3,
when: 1773100800000,
tag: "0003_app_shell_organization_profile",
breakpoints: true,
},
{
idx: 4,
when: 1773100800001,
tag: "0004_app_shell_organization_members",
breakpoints: true,
},
{
idx: 5,
when: 1773100800002,
tag: "0005_app_shell_seat_assignments",
breakpoints: true,
},
{
idx: 6,
when: 1773100800003,
tag: "0006_app_shell_invoices",
breakpoints: true,
},
{
idx: 7,
when: 1773100800004,
tag: "0007_app_shell_sessions",
breakpoints: true,
},
{
idx: 8,
when: 1773100800005,
tag: "0008_app_shell_stripe_lookup",
breakpoints: true,
},
{
idx: 9,
when: 1773100800006,
tag: "0009_github_sync_status",
breakpoints: true,
},
{
idx: 10,
when: 1772928000000,
tag: "0010_app_session_starter_repo",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`provider_profiles\` (
\`provider_id\` text PRIMARY KEY NOT NULL,
\`profile_json\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `CREATE TABLE \`repos\` (
\`repo_id\` text PRIMARY KEY NOT NULL,
\`remote_url\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0002: `CREATE TABLE \`task_lookup\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`repo_id\` text NOT NULL
);
`,
m0003: `CREATE TABLE \`organization_profile\` (
\`id\` text PRIMARY KEY NOT NULL,
\`kind\` text NOT NULL,
\`github_account_id\` text NOT NULL,
\`github_login\` text NOT NULL,
\`github_account_type\` text NOT NULL,
\`display_name\` text NOT NULL,
\`slug\` text NOT NULL,
\`primary_domain\` text NOT NULL,
\`default_model\` text NOT NULL,
\`auto_import_repos\` integer NOT NULL,
\`repo_import_status\` text NOT NULL,
\`github_connected_account\` text NOT NULL,
\`github_installation_status\` text NOT NULL,
\`github_installation_id\` integer,
\`github_last_sync_label\` text NOT NULL,
\`stripe_customer_id\` text,
\`stripe_subscription_id\` text,
\`stripe_price_id\` text,
\`billing_plan_id\` text NOT NULL,
\`billing_status\` text NOT NULL,
\`billing_seats_included\` integer NOT NULL,
\`billing_trial_ends_at\` text,
\`billing_renewal_at\` text,
\`billing_payment_method_label\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0004: `CREATE TABLE \`organization_members\` (
\`id\` text PRIMARY KEY NOT NULL,
\`name\` text NOT NULL,
\`email\` text NOT NULL,
\`role\` text NOT NULL,
\`state\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0005: `CREATE TABLE \`seat_assignments\` (
\`email\` text PRIMARY KEY NOT NULL,
\`created_at\` integer NOT NULL
);
`,
m0006: `CREATE TABLE \`invoices\` (
\`id\` text PRIMARY KEY NOT NULL,
\`label\` text NOT NULL,
\`issued_at\` text NOT NULL,
\`amount_usd\` integer NOT NULL,
\`status\` text NOT NULL,
\`created_at\` integer NOT NULL
);
`,
m0007: `CREATE TABLE \`app_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`current_user_id\` text,
\`current_user_name\` text,
\`current_user_email\` text,
\`current_user_github_login\` text,
\`current_user_role_label\` text,
\`eligible_organization_ids_json\` text NOT NULL,
\`active_organization_id\` text,
\`github_access_token\` text,
\`github_scope\` text NOT NULL,
\`starter_repo_status\` text NOT NULL,
\`starter_repo_starred_at\` integer,
\`starter_repo_skipped_at\` integer,
\`oauth_state\` text,
\`oauth_state_expires_at\` integer,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0008: `CREATE TABLE \`stripe_lookup\` (
\`lookup_key\` text PRIMARY KEY NOT NULL,
\`organization_id\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0009: `ALTER TABLE \`organization_profile\` ADD COLUMN \`github_sync_status\` text NOT NULL DEFAULT 'pending';
ALTER TABLE \`organization_profile\` ADD COLUMN \`github_last_sync_at\` integer;
UPDATE \`organization_profile\`
SET \`github_sync_status\` = CASE
WHEN \`repo_import_status\` = 'ready' THEN 'synced'
WHEN \`repo_import_status\` = 'importing' THEN 'syncing'
ELSE 'pending'
END;
`,
m0010: `ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_status\` text NOT NULL DEFAULT 'pending';
ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_starred_at\` integer;
ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_skipped_at\` integer;
`,
} as const,
};

View file

@ -0,0 +1,100 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per workspace actor instance, so no workspaceId column needed.
export const providerProfiles = sqliteTable("provider_profiles", {
providerId: text("provider_id").notNull().primaryKey(),
profileJson: text("profile_json").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const repos = sqliteTable("repos", {
repoId: text("repo_id").notNull().primaryKey(),
remoteUrl: text("remote_url").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const taskLookup = sqliteTable("task_lookup", {
taskId: text("task_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
});
export const organizationProfile = sqliteTable("organization_profile", {
id: text("id").notNull().primaryKey(),
kind: text("kind").notNull(),
githubAccountId: text("github_account_id").notNull(),
githubLogin: text("github_login").notNull(),
githubAccountType: text("github_account_type").notNull(),
displayName: text("display_name").notNull(),
slug: text("slug").notNull(),
primaryDomain: text("primary_domain").notNull(),
defaultModel: text("default_model").notNull(),
autoImportRepos: integer("auto_import_repos").notNull(),
repoImportStatus: text("repo_import_status").notNull(),
githubConnectedAccount: text("github_connected_account").notNull(),
githubInstallationStatus: text("github_installation_status").notNull(),
githubSyncStatus: text("github_sync_status").notNull(),
githubInstallationId: integer("github_installation_id"),
githubLastSyncLabel: text("github_last_sync_label").notNull(),
githubLastSyncAt: integer("github_last_sync_at"),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripePriceId: text("stripe_price_id"),
billingPlanId: text("billing_plan_id").notNull(),
billingStatus: text("billing_status").notNull(),
billingSeatsIncluded: integer("billing_seats_included").notNull(),
billingTrialEndsAt: text("billing_trial_ends_at"),
billingRenewalAt: text("billing_renewal_at"),
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const organizationMembers = sqliteTable("organization_members", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
role: text("role").notNull(),
state: text("state").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const seatAssignments = sqliteTable("seat_assignments", {
email: text("email").notNull().primaryKey(),
createdAt: integer("created_at").notNull(),
});
export const invoices = sqliteTable("invoices", {
id: text("id").notNull().primaryKey(),
label: text("label").notNull(),
issuedAt: text("issued_at").notNull(),
amountUsd: integer("amount_usd").notNull(),
status: text("status").notNull(),
createdAt: integer("created_at").notNull(),
});
export const appSessions = sqliteTable("app_sessions", {
id: text("id").notNull().primaryKey(),
currentUserId: text("current_user_id"),
currentUserName: text("current_user_name"),
currentUserEmail: text("current_user_email"),
currentUserGithubLogin: text("current_user_github_login"),
currentUserRoleLabel: text("current_user_role_label"),
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
activeOrganizationId: text("active_organization_id"),
githubAccessToken: text("github_access_token"),
githubScope: text("github_scope").notNull(),
starterRepoStatus: text("starter_repo_status").notNull(),
starterRepoStarredAt: integer("starter_repo_starred_at"),
starterRepoSkippedAt: integer("starter_repo_skipped_at"),
oauthState: text("oauth_state"),
oauthStateExpiresAt: integer("oauth_state_expires_at"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const stripeLookup = sqliteTable("stripe_lookup", {
lookupKey: text("lookup_key").notNull().primaryKey(),
organizationId: text("organization_id").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -0,0 +1,17 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { workspaceDb } from "./db/db.js";
import { runWorkspaceWorkflow, WORKSPACE_QUEUE_NAMES, workspaceActions } from "./actions.js";
export const workspace = actor({
db: workspaceDb,
queues: Object.fromEntries(WORKSPACE_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000,
},
createState: (_c, workspaceId: string) => ({
workspaceId,
}),
actions: workspaceActions,
run: workflow(runWorkspaceWorkflow),
});

View file

@ -0,0 +1,22 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { homedir } from "node:os";
import * as toml from "@iarna/toml";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared";
export const CONFIG_PATH = `${homedir()}/.config/foundry/config.toml`;
export function loadConfig(path = CONFIG_PATH): AppConfig {
if (!existsSync(path)) {
return ConfigSchema.parse({});
}
const raw = readFileSync(path, "utf8");
const parsed = toml.parse(raw) as unknown;
return ConfigSchema.parse(parsed);
}
export function saveConfig(config: AppConfig, path = CONFIG_PATH): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, toml.stringify(config), "utf8");
}

Some files were not shown because too many files have changed in this diff Show more