17 KiB
RivetKit + Sandbox Provider + OpenTUI Migration Spec
Status: implemented baseline (Phase 1-4 complete, Phase 5 partial) Date: 2026-02-08
Locked Decisions
- Entire rewrite is TypeScript. All Rust code will be deleted at cutover.
- Repo stays a single monorepo, managed with
pnpmorganizations + Turborepo. corepackage is renamed toshared.integrationsandproviderslive inside the backend package (not top-level packages).- Rivet-backed state uses SQLite + Drizzle only.
- RivetKit dependencies come from local
../rivetbuilds only; no published npm packages. - Everything is organization-scoped. Organization is configurable from CLI.
ControlPlaneActoris renamed toOrganizationActor(organization coordinator).- Every actor key is prefixed by organization.
--organizationis optional; commands resolve organization via flag -> config default ->default.- RivetKit local dependency wiring is
link:-based. - Keep the existing config file path (
~/.config/foundry/config.toml) and evolve keys in place. .agentsand skill files are in scope for migration updates.- Parent orchestration actors (
organization,repository,task) use command-only loops with no timeout. - Periodic syncing/polling runs in dedicated child actors, each with a single timeout cadence.
- For each actor, define the main loop and exactly what data it mutates; keep single-writer ownership strict.
Executive Summary
We will replace the existing Rust backend/CLI/TUI with TypeScript services and UIs:
- Backend: RivetKit actor runtime
- Agent orchestration: Sandbox Agent through provider adapters
- CLI: TypeScript
- TUI: TypeScript + OpenTUI
- State: SQLite + Drizzle (actor-owned writes)
The core architecture changes from "worktree-per-task" to "provider-selected sandbox-per-task." Local worktrees remain supported through a worktree provider.
Breaking Changes (Intentional)
- Rust binaries/backend removed.
- Existing IPC replaced by new TypeScript transport.
- Configuration schema changes for organization selection and sandbox provider defaults.
- Runtime model changes from global control plane to organization coordinator actor.
- Database schema migrates to organization + provider + sandbox identity model.
- Command options evolve to include organization and provider selection.
Monorepo and Build Tooling
Root tooling is standardized:
pnpm-workspace.yamlturbo.json- organization scripts through
pnpm+turbo run ...
Target package layout:
packages/
shared/ # shared types, contracts, validation
backend/
src/
actors/
organization.ts
repository.ts
task.ts
sandbox-instance.ts
history.ts
repository-pr-sync.ts
repository-branch-sync.ts
task-status-sync.ts
keys.ts
events.ts
registry.ts
index.ts
providers/ # provider-api + implementations
provider-api/
worktree/
daytona/
integrations/ # sandbox-agent + git/github/graphite adapters
sandbox-agent/
git/
github/
graphite/
db/ # drizzle schema, queries, migrations
schema.ts
client.ts
migrations/
transport/
server.ts
types.ts
config/
organization.ts
backend.ts
cli/ # hf command surface
src/
commands/
client/ # backend transport client
organization/ # organization selection resolver
tui/ # OpenTUI app
src/
app/
views/
state/
client/ # backend stream client
research/specs/
rivetkit-opentui-migration-plan.md (this file)
CLI and TUI are separate packages in the same monorepo, not separate repositories.
Actor File Map (Concrete)
Backend actor files and responsibilities:
packages/backend/src/actors/organization.ts
OrganizationActorimplementation.- Provider profile resolution and organization-level coordination.
- Spawns/routes to
RepositoryActorhandles.
packages/backend/src/actors/repository.ts
RepositoryActorimplementation.- Branch snapshot refresh, PR cache orchestration, stream publication.
- Routes task actions to
TaskActor.
packages/backend/src/actors/task.ts
TaskActorimplementation.- Task lifecycle, session/sandbox orchestration, post-idle automation.
packages/backend/src/actors/sandbox-instance.ts
SandboxInstanceActorimplementation.- Provider sandbox lifecycle, heartbeat, reconnect/recovery.
packages/backend/src/actors/history.ts
HistoryActorimplementation.- Writes workflow events to SQLite via Drizzle.
packages/backend/src/actors/keys.ts
- Organization-prefixed actor key builders/parsers.
packages/backend/src/actors/events.ts
- Internal actor event envelopes and stream payload types.
packages/backend/src/actors/registry.ts
- RivetKit registry setup and actor registration.
packages/backend/src/actors/index.ts
- Actor exports and composition wiring.
packages/backend/src/actors/repository-pr-sync.ts
- Read-only PR polling loop (single timeout cadence).
- Sends sync results back to
RepositoryActor.
packages/backend/src/actors/repository-branch-sync.ts
- Read-only branch snapshot polling loop (single timeout cadence).
- Sends sync results back to
RepositoryActor.
packages/backend/src/actors/task-status-sync.ts
- Read-only session/sandbox status polling loop (single timeout cadence).
- Sends status updates back to
TaskActor.
RivetKit Source Policy (Local Only)
Do not use published RivetKit packages.
- Build RivetKit from local source:
cd ../rivet
pnpm build -F rivetkit
- Consume via local
link:dependencies to built artifacts. - Keep dependency wiring deterministic and documented in repo scripts.
Organization Model
Every command executes against a resolved organization context.
Organization selection:
- CLI flag:
--organization <name> - Config default organization
- Fallback to
default
Organization controls:
- provider profile defaults
- sandbox policy
- repo membership / resolution
- actor namespaces and database partitioning
New Actor Implementation Overview
RivetKit registry actor keys are organization-prefixed:
OrganizationActor(organization coordinator)
- Key:
["ws", organizationId] - Owns organization config/runtime coordination, provider registry, organization health.
- Resolves provider defaults and organization-level policies.
RepositoryActor
- Key:
["ws", organizationId, "repository", repoId] - Owns repo snapshot cache and PR cache refresh orchestration.
- Routes branch/task commands to task actors.
- Streams repository updates to CLI/TUI subscribers.
TaskActor
- Key:
["ws", organizationId, "repository", repoId, "task", taskId] - Owns task metadata/runtime state.
- Creates/resumes sandbox + session through provider adapter.
- Handles attach/push/sync/merge/archive/kill and post-idle automation.
SandboxInstanceActor(optional but recommended)
- Key:
["ws", organizationId, "provider", providerId, "sandbox", sandboxId] - Owns sandbox lifecycle, heartbeat, endpoint readiness, recovery.
HistoryActor
- Key:
["ws", organizationId, "repository", repoId, "history"] - Owns
eventswrites and workflow timeline completeness.
ProjectPrSyncActor(child poller)
- Key:
["ws", organizationId, "repository", repoId, "pr-sync"] - Polls PR state on interval and emits results to
RepositoryActor. - Does not write DB directly.
ProjectBranchSyncActor(child poller)
- Key:
["ws", organizationId, "repository", repoId, "branch-sync"] - Polls branch/worktree state on interval and emits results to
RepositoryActor. - Does not write DB directly.
TaskStatusSyncActor(child poller)
- Key:
["ws", organizationId, "repository", repoId, "task", taskId, "status-sync"] - Polls agent/session/sandbox health on interval and emits results to
TaskActor. - Does not write DB directly.
Ownership rule: each table/row has one actor writer.
Single-Writer Mutation Map
Always define actor run-loop + mutated state together:
OrganizationActor
- Mutates:
organizations,workspace_provider_profiles.
RepositoryActor
- Mutates:
repos,branches,pr_cache(applies child poller results).
TaskActor
- Mutates:
tasks,task_runtime(applies child poller results).
SandboxInstanceActor
- Mutates:
sandbox_instances.
HistoryActor
- Mutates:
events.
- Child sync actors (
repository-pr-sync,repository-branch-sync,task-status-sync)
- Mutates: none (read-only pollers; publish result messages only).
Run Loop Patterns (Required)
Parent orchestration actors: no timeout, command-only queue loops.
OrganizationActor (no timeout)
run: async (c) => {
while (true) {
const msg = await c.queue.next("organization.command");
await handleOrganizationCommand(c, msg); // writes organization-owned tables only
}
};
RepositoryActor (no timeout)
run: async (c) => {
while (true) {
const msg = await c.queue.next("repository.command");
await handleProjectCommand(c, msg); // includes applying sync results to branches/pr_cache
}
};
TaskActor (no timeout)
run: async (c) => {
while (true) {
const msg = await c.queue.next("task.command");
await handleTaskCommand(c, msg); // includes applying status results to task_runtime
}
};
SandboxInstanceActor (no timeout)
run: async (c) => {
while (true) {
const msg = await c.queue.next("sandbox_instance.command");
await handleSandboxInstanceCommand(c, msg); // sandbox_instances table only
}
};
HistoryActor (no timeout)
run: async (c) => {
while (true) {
const msg = await c.queue.next("history.command");
await persistEvent(c, msg); // events table only
}
};
Child sync actors: one timeout each, one cadence each.
ProjectPrSyncActor (single timeout cadence)
run: async (c) => {
const intervalMs = 30_000;
while (true) {
const msg = await c.queue.next("repository.pr_sync.command", { timeout: intervalMs });
if (!msg) {
const result = await pollPrState();
await sendToProject({ name: "repository.pr_sync.result", result });
continue;
}
await handlePrSyncControl(c, msg); // force/stop/update-interval
}
};
ProjectBranchSyncActor (single timeout cadence)
run: async (c) => {
const intervalMs = 5_000;
while (true) {
const msg = await c.queue.next("repository.branch_sync.command", { timeout: intervalMs });
if (!msg) {
const result = await pollBranchState();
await sendToProject({ name: "repository.branch_sync.result", result });
continue;
}
await handleBranchSyncControl(c, msg);
}
};
TaskStatusSyncActor (single timeout cadence)
run: async (c) => {
const intervalMs = 2_000;
while (true) {
const msg = await c.queue.next("task.status_sync.command", { timeout: intervalMs });
if (!msg) {
const result = await pollSessionAndSandboxStatus();
await sendToTask({ name: "task.status_sync.result", result });
continue;
}
await handleStatusSyncControl(c, msg);
}
};
Sandbox Provider Interface
Provider contract lives under packages/backend/src/providers/provider-api and is consumed by organization/repository/task actors.
interface SandboxProvider {
id(): string;
capabilities(): ProviderCapabilities;
validateConfig(input: unknown): Promise<ValidatedConfig>;
createSandbox(req: CreateSandboxRequest): Promise<SandboxHandle>;
resumeSandbox(req: ResumeSandboxRequest): Promise<SandboxHandle>;
destroySandbox(req: DestroySandboxRequest): Promise<void>;
ensureSandboxAgent(req: EnsureAgentRequest): Promise<AgentEndpoint>;
health(req: SandboxHealthRequest): Promise<SandboxHealth>;
attachTarget(req: AttachTargetRequest): Promise<AttachTarget>;
}
Initial providers:
worktree
- Local git worktree-backed sandbox.
- Sandbox Agent local/shared endpoint.
- Preserves tmux +
cdergonomics.
daytona
- Remote sandbox lifecycle via Daytona.
- Boots/ensures Sandbox Agent inside sandbox.
- Returns endpoint/token for session operations.
Command Surface (Organization + Provider Aware)
hf create ... --organization <ws> --provider <worktree|daytona>hf switch --organization <ws> [target]hf attach --organization <ws> [task]hf list --organization <ws>hf kill|archive|merge|push|sync --organization <ws> ...hf organization use <ws>to set default organization
List/TUI include provider and sandbox health metadata.
--organization remains optional; omitted values use the standard resolution order.
Data Model v2 (SQLite + Drizzle)
All persistent state is SQLite via Drizzle schema + migrations.
Tables (organization-scoped):
organizationsworkspace_provider_profilesrepos(workspace_id,repo_id, ...)branches(workspace_id,repo_id, ...)tasks(workspace_id,task_id,provider_id, ...)task_runtime(workspace_id,task_id,sandbox_id,session_id, ...)sandbox_instances(workspace_id,provider_id,sandbox_id, ...)pr_cache(workspace_id,repo_id, ...)events(workspace_id,repo_id, ...)
Migration approach: one-way migration from existing schema during TS backend bootstrap.
Transport and Runtime
- TypeScript backend exposes local control API (socket or localhost HTTP).
- CLI/TUI are thin clients; all mutations go through backend actors.
- OpenTUI subscribes to repository streams from organization-scoped repository actors.
- Organization is required context on all backend mutation requests.
CLI/TUI are responsible for resolving organization context before calling backend mutations.
CLI + TUI Packaging
CLI and TUI ship from one package:
packages/cli
- command-oriented UX (
hf create,hf push, scripting, JSON output) - interactive OpenTUI mode via
hf tui - shared client/runtime wiring in one distributable
The package still calls the same backend API and shares contracts from packages/shared.
Implementation Phases
Phase 0: Contracts and Organization Spec
- Freeze organization model, provider contract, and actor ownership map.
- Freeze command flags for organization + provider selection.
- Define Drizzle schema draft and migration plan.
Exit criteria:
- Approved architecture RFC.
Phase 1: TypeScript Monorepo Bootstrap
- Add
pnpmorganization + Turborepo pipeline. - Create
shared,backend, andclipackages (with TUI integrated into CLI). - Add strict TypeScript config and CI checks.
Exit criteria:
pnpm -w typecheckandturbo run buildpass.
Phase 2: RivetKit + Drizzle Foundations
- Wire local RivetKit dependency from
../rivet. - Add SQLite + Drizzle migrations and query layer.
- Implement actor registry with organization-prefixed keys.
Exit criteria:
- Backend boot + organization actor health checks pass.
Phase 3: Provider Layer in Backend
- Implement provider API inside backend package.
- Implement
worktreeprovider end-to-end. - Integrate sandbox-agent session lifecycle through provider.
Exit criteria:
create/list/switch/attach/push/sync/killpass on worktree provider.
Phase 4: Organization/Task Lifecycle
- Implement organization coordinator flows.
- Implement TaskActor full lifecycle + post-idle automation.
- Implement history events and PR/CI/review change tracking.
Exit criteria:
- history/event completeness with parity checks.
Phase 5: Daytona Provider
- Implement Daytona sandbox lifecycle adapter.
- Ensure sandbox-agent boot and reconnection behavior.
- Validate attach/switch/kill flows for remote sandboxes.
Exit criteria:
- e2e pass on daytona provider.
Phase 6: OpenTUI Rewrite
- Build interactive list/switch UI in OpenTUI.
- Implement key actions (attach/open PR/archive/merge/sync).
- Add organization switcher UX and provider/sandbox indicators.
Exit criteria:
- TUI parity and responsive streaming updates.
Phase 7: Cutover + Rust Deletion
- Migrate existing DB to v2.
- Replace runtime entrypoints with TS CLI/backend/TUI.
- Delete Rust code, Cargo files, Rust scripts.
- Update docs and
skills/SKILL.md.
Exit criteria:
- no Rust code remains, fresh install + upgrade validated.
Testing Strategy
- Unit tests
- actor actions and ownership rules
- provider adapters
- event emission correctness
- drizzle query/migration tests
- Integration tests
- backend + sqlite + provider fakes
- organization isolation boundaries
- session recovery and restart handling
- E2E tests
- worktree provider in local test repo
- daytona provider in controlled env
- OpenTUI interactive flows
- Reliability tests
- sandbox-agent restarts
- transient provider failures
- backend restart with in-flight tasks
Open Questions To Resolve Before Implementation
- Daytona production adapter parity:
- Current
daytonaprovider in this repo is intentionally a fallback adapter so local development remains testable without a Daytona backend. - Final deployment integration should replace placeholder lifecycle calls with Daytona API operations (create/destroy/health/auth/session boot inside sandbox).