mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
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:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
|
|
@ -17,7 +17,7 @@ coverage/
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
.openhandoff/
|
.foundry/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
27
.env.development.example
Normal file
27
.env.development.example
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Load this file only when NODE_ENV=development.
|
||||||
|
# The backend does not load dotenv files in production.
|
||||||
|
|
||||||
|
APP_URL=http://localhost:4173
|
||||||
|
BETTER_AUTH_URL=http://localhost:4173
|
||||||
|
BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me
|
||||||
|
GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback
|
||||||
|
|
||||||
|
# Fill these in when enabling live GitHub OAuth.
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Fill these in when enabling GitHub App-backed org installation and repo import.
|
||||||
|
GITHUB_APP_ID=
|
||||||
|
GITHUB_APP_CLIENT_ID=
|
||||||
|
GITHUB_APP_CLIENT_SECRET=
|
||||||
|
# Store PEM material as a quoted single-line value with \n escapes.
|
||||||
|
GITHUB_APP_PRIVATE_KEY=
|
||||||
|
# Webhook secret for verifying GitHub webhook payloads.
|
||||||
|
# Use smee.io for local development: https://smee.io/new
|
||||||
|
GITHUB_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Fill these in when enabling live Stripe billing.
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_PRICE_TEAM=
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -51,7 +51,7 @@ Cargo.lock
|
||||||
# Example temp files
|
# Example temp files
|
||||||
.tmp-upload/
|
.tmp-upload/
|
||||||
*.db
|
*.db
|
||||||
.openhandoff/
|
.foundry/
|
||||||
|
|
||||||
# CLI binaries (downloaded during npm publish)
|
# CLI binaries (downloaded during npm publish)
|
||||||
sdks/cli/platforms/*/bin/
|
sdks/cli/platforms/*/bin/
|
||||||
|
|
|
||||||
153
docs/deploy/foundry-self-hosting.mdx
Normal file
153
docs/deploy/foundry-self-hosting.mdx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
---
|
||||||
|
title: "Foundry Self-Hosting"
|
||||||
|
description: "Environment, credentials, and deployment setup for Sandbox Agent Foundry auth, GitHub, and billing."
|
||||||
|
---
|
||||||
|
|
||||||
|
This guide documents the deployment contract for the Foundry product surface: app auth, GitHub onboarding, repository import, and billing.
|
||||||
|
|
||||||
|
It also covers the local-development bootstrap that uses `.env.development` only when `NODE_ENV=development`.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
For backend local development, the Foundry backend now supports a development-only dotenv bootstrap:
|
||||||
|
|
||||||
|
- It loads `.env.development.local` and `.env.development`
|
||||||
|
- It does this **only** when `NODE_ENV=development`
|
||||||
|
- It does **not** load dotenv files in production
|
||||||
|
|
||||||
|
The example file lives at [`/.env.development.example`](https://github.com/rivet-dev/sandbox-agent/blob/main/.env.development.example).
|
||||||
|
|
||||||
|
To use it locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.development.example .env.development
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the backend with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just foundry-backend-start
|
||||||
|
```
|
||||||
|
|
||||||
|
That recipe sets `NODE_ENV=development`, which enables the dotenv loader.
|
||||||
|
|
||||||
|
### Local Defaults
|
||||||
|
|
||||||
|
These values can be safely defaulted for local development:
|
||||||
|
|
||||||
|
- `APP_URL=http://localhost:4173`
|
||||||
|
- `BETTER_AUTH_URL=http://localhost:4173`
|
||||||
|
- `BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me`
|
||||||
|
- `GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback`
|
||||||
|
|
||||||
|
These should be treated as development-only values.
|
||||||
|
|
||||||
|
## Production Environment
|
||||||
|
|
||||||
|
For production or self-hosting, set these as real environment variables in your deployment platform. Do not rely on dotenv file loading.
|
||||||
|
|
||||||
|
### App/Auth
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|---|---:|---|
|
||||||
|
| `APP_URL` | Yes | Public frontend origin |
|
||||||
|
| `BETTER_AUTH_URL` | Yes | Public auth base URL |
|
||||||
|
| `BETTER_AUTH_SECRET` | Yes | Strong random secret for auth/session signing |
|
||||||
|
|
||||||
|
### GitHub OAuth
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|---|---:|---|
|
||||||
|
| `GITHUB_CLIENT_ID` | Yes | GitHub OAuth app client id |
|
||||||
|
| `GITHUB_CLIENT_SECRET` | Yes | GitHub OAuth app client secret |
|
||||||
|
| `GITHUB_REDIRECT_URI` | Yes | GitHub OAuth callback URL |
|
||||||
|
|
||||||
|
Use GitHub OAuth for:
|
||||||
|
|
||||||
|
- user sign-in
|
||||||
|
- user identity
|
||||||
|
- org selection
|
||||||
|
- access to the signed-in user’s GitHub context
|
||||||
|
|
||||||
|
## GitHub App
|
||||||
|
|
||||||
|
If your Foundry deployment uses GitHub App-backed organization install and repo import, also configure:
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|---|---:|---|
|
||||||
|
| `GITHUB_APP_ID` | Yes | GitHub App id |
|
||||||
|
| `GITHUB_APP_CLIENT_ID` | Yes | GitHub App client id |
|
||||||
|
| `GITHUB_APP_CLIENT_SECRET` | Yes | GitHub App client secret |
|
||||||
|
| `GITHUB_APP_PRIVATE_KEY` | Yes | PEM private key for installation auth |
|
||||||
|
|
||||||
|
For `.env.development` and `.env.development.local`, store `GITHUB_APP_PRIVATE_KEY` as a quoted single-line value with `\n` escapes instead of raw multi-line PEM text.
|
||||||
|
|
||||||
|
Recommended GitHub App permissions:
|
||||||
|
|
||||||
|
- Repository `Metadata: Read`
|
||||||
|
- Repository `Contents: Read & Write`
|
||||||
|
- Repository `Pull requests: Read & Write`
|
||||||
|
- Repository `Checks: Read`
|
||||||
|
- Repository `Commit statuses: Read`
|
||||||
|
|
||||||
|
Set the webhook URL to `https://<your-backend-host>/api/rivet/app/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
|
Recommended webhook subscriptions:
|
||||||
|
|
||||||
|
- `installation`
|
||||||
|
- `installation_repositories`
|
||||||
|
- `pull_request`
|
||||||
|
- `pull_request_review`
|
||||||
|
- `pull_request_review_comment`
|
||||||
|
- `push`
|
||||||
|
- `create`
|
||||||
|
- `delete`
|
||||||
|
- `check_suite`
|
||||||
|
- `check_run`
|
||||||
|
- `status`
|
||||||
|
|
||||||
|
Use the GitHub App for:
|
||||||
|
|
||||||
|
- installation/reconnect state
|
||||||
|
- org repo import
|
||||||
|
- repository sync
|
||||||
|
- PR creation and updates
|
||||||
|
|
||||||
|
Use GitHub OAuth for:
|
||||||
|
|
||||||
|
- who the user is
|
||||||
|
- which orgs they can choose
|
||||||
|
|
||||||
|
## Stripe
|
||||||
|
|
||||||
|
For live billing, configure:
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|---|---:|---|
|
||||||
|
| `STRIPE_SECRET_KEY` | Yes | Server-side Stripe secret key |
|
||||||
|
| `STRIPE_PUBLISHABLE_KEY` | Yes | Client-side Stripe publishable key |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | Yes | Signing secret for billing webhooks |
|
||||||
|
| `STRIPE_PRICE_TEAM` | Yes | Stripe price id for the Team plan checkout session |
|
||||||
|
|
||||||
|
Stripe should own:
|
||||||
|
|
||||||
|
- hosted checkout
|
||||||
|
- billing portal
|
||||||
|
- subscription status
|
||||||
|
- invoice history
|
||||||
|
- webhook-driven state sync
|
||||||
|
|
||||||
|
## Mock Invariant
|
||||||
|
|
||||||
|
Foundry’s mock client path should continue to work end to end even when the real auth/GitHub/Stripe path exists.
|
||||||
|
|
||||||
|
That includes:
|
||||||
|
|
||||||
|
- sign-in
|
||||||
|
- org selection/import
|
||||||
|
- settings
|
||||||
|
- billing UI
|
||||||
|
- workspace/task/session flow
|
||||||
|
- seat accrual
|
||||||
|
|
||||||
|
Use mock mode for deterministic UI review and local product development. Use the real env-backed path for integration and self-hosting.
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
name: openhandoff
|
|
||||||
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: factory/docker/backend.dev.Dockerfile
|
|
||||||
image: openhandoff-backend-dev
|
|
||||||
working_dir: /app
|
|
||||||
environment:
|
|
||||||
HF_BACKEND_HOST: "0.0.0.0"
|
|
||||||
HF_BACKEND_PORT: "7741"
|
|
||||||
HF_RIVET_MANAGER_PORT: "8750"
|
|
||||||
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
|
|
||||||
# Pass through credentials needed for agent execution + PR creation in dev/e2e.
|
|
||||||
# Do not hardcode secrets; set these in your environment when starting compose.
|
|
||||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
|
||||||
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
|
|
||||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
|
||||||
# sandbox-agent codex plugin currently expects CODEX_API_KEY. Map from OPENAI_API_KEY for convenience.
|
|
||||||
CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}"
|
|
||||||
# Support either GITHUB_TOKEN or GITHUB_PAT in local env files.
|
|
||||||
GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}"
|
|
||||||
GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}"
|
|
||||||
DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}"
|
|
||||||
DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}"
|
|
||||||
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"
|
|
||||||
HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}"
|
|
||||||
ports:
|
|
||||||
- "7741:7741"
|
|
||||||
# RivetKit manager (used by browser clients after /api/rivet metadata redirect in dev)
|
|
||||||
- "8750:8750"
|
|
||||||
volumes:
|
|
||||||
- "..:/app"
|
|
||||||
# The linked RivetKit checkout resolves from factory packages to /handoff/rivet-checkout in-container.
|
|
||||||
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
|
|
||||||
# Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev.
|
|
||||||
- "${HOME}/.codex:/root/.codex"
|
|
||||||
# Keep backend dependency installs Linux-native instead of using host node_modules.
|
|
||||||
- "openhandoff_backend_root_node_modules:/app/node_modules"
|
|
||||||
- "openhandoff_backend_backend_node_modules:/app/factory/packages/backend/node_modules"
|
|
||||||
- "openhandoff_backend_shared_node_modules:/app/factory/packages/shared/node_modules"
|
|
||||||
- "openhandoff_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules"
|
|
||||||
- "openhandoff_backend_typescript_node_modules:/app/sdks/typescript/node_modules"
|
|
||||||
- "openhandoff_backend_pnpm_store:/root/.local/share/pnpm/store"
|
|
||||||
# Persist backend-managed local git clones across container restarts.
|
|
||||||
- "openhandoff_git_repos:/root/.local/share/openhandoff/repos"
|
|
||||||
# Persist RivetKit local storage across container restarts.
|
|
||||||
- "openhandoff_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: factory/docker/frontend.dev.Dockerfile
|
|
||||||
working_dir: /app
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
environment:
|
|
||||||
HOME: "/tmp"
|
|
||||||
HF_BACKEND_HTTP: "http://backend:7741"
|
|
||||||
ports:
|
|
||||||
- "4173:4173"
|
|
||||||
volumes:
|
|
||||||
- "..:/app"
|
|
||||||
# Ensure logs in .openhandoff/ persist on the host even if we change source mounts later.
|
|
||||||
- "./.openhandoff:/app/factory/.openhandoff"
|
|
||||||
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
|
|
||||||
# Use Linux-native workspace dependencies inside the container instead of host node_modules.
|
|
||||||
- "openhandoff_node_modules:/app/node_modules"
|
|
||||||
- "openhandoff_client_node_modules:/app/factory/packages/client/node_modules"
|
|
||||||
- "openhandoff_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules"
|
|
||||||
- "openhandoff_frontend_node_modules:/app/factory/packages/frontend/node_modules"
|
|
||||||
- "openhandoff_shared_node_modules:/app/factory/packages/shared/node_modules"
|
|
||||||
- "openhandoff_pnpm_store:/tmp/.local/share/pnpm/store"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openhandoff_backend_root_node_modules: {}
|
|
||||||
openhandoff_backend_backend_node_modules: {}
|
|
||||||
openhandoff_backend_shared_node_modules: {}
|
|
||||||
openhandoff_backend_persist_rivet_node_modules: {}
|
|
||||||
openhandoff_backend_typescript_node_modules: {}
|
|
||||||
openhandoff_backend_pnpm_store: {}
|
|
||||||
openhandoff_git_repos: {}
|
|
||||||
openhandoff_rivetkit_storage: {}
|
|
||||||
openhandoff_node_modules: {}
|
|
||||||
openhandoff_client_node_modules: {}
|
|
||||||
openhandoff_frontend_errors_node_modules: {}
|
|
||||||
openhandoff_frontend_node_modules: {}
|
|
||||||
openhandoff_shared_node_modules: {}
|
|
||||||
openhandoff_pnpm_store: {}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import migrations from "./migrations.js";
|
|
||||||
|
|
||||||
export const handoffDb = actorSqliteDb({
|
|
||||||
actorName: "handoff",
|
|
||||||
schema,
|
|
||||||
migrations,
|
|
||||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { defineConfig } from "rivetkit/db/drizzle";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
out: "./src/actors/handoff/db/drizzle",
|
|
||||||
schema: "./src/actors/handoff/db/schema.ts",
|
|
||||||
});
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
ALTER TABLE `handoff` DROP COLUMN `auto_committed`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `handoff` DROP COLUMN `pushed`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `handoff` DROP COLUMN `needs_push`;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE `handoff_sandboxes` ADD `sandbox_actor_id` text;
|
|
||||||
|
|
@ -1,389 +0,0 @@
|
||||||
import { actor, queue } from "rivetkit";
|
|
||||||
import { workflow } from "rivetkit/workflow";
|
|
||||||
import type {
|
|
||||||
AgentType,
|
|
||||||
HandoffRecord,
|
|
||||||
HandoffWorkbenchChangeModelInput,
|
|
||||||
HandoffWorkbenchRenameInput,
|
|
||||||
HandoffWorkbenchRenameSessionInput,
|
|
||||||
HandoffWorkbenchSetSessionUnreadInput,
|
|
||||||
HandoffWorkbenchSendMessageInput,
|
|
||||||
HandoffWorkbenchUpdateDraftInput,
|
|
||||||
ProviderId,
|
|
||||||
} from "@openhandoff/shared";
|
|
||||||
import { expectQueueResponse } from "../../services/queue.js";
|
|
||||||
import { selfHandoff } from "../handles.js";
|
|
||||||
import { handoffDb } from "./db/db.js";
|
|
||||||
import { getCurrentRecord } from "./workflow/common.js";
|
|
||||||
import {
|
|
||||||
changeWorkbenchModel,
|
|
||||||
closeWorkbenchSession,
|
|
||||||
createWorkbenchSession,
|
|
||||||
getWorkbenchHandoff,
|
|
||||||
markWorkbenchUnread,
|
|
||||||
publishWorkbenchPr,
|
|
||||||
renameWorkbenchBranch,
|
|
||||||
renameWorkbenchHandoff,
|
|
||||||
renameWorkbenchSession,
|
|
||||||
revertWorkbenchFile,
|
|
||||||
sendWorkbenchMessage,
|
|
||||||
syncWorkbenchSessionStatus,
|
|
||||||
setWorkbenchSessionUnread,
|
|
||||||
stopWorkbenchSession,
|
|
||||||
updateWorkbenchDraft,
|
|
||||||
} from "./workbench.js";
|
|
||||||
import { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName, runHandoffWorkflow } from "./workflow/index.js";
|
|
||||||
|
|
||||||
export interface HandoffInput {
|
|
||||||
workspaceId: string;
|
|
||||||
repoId: string;
|
|
||||||
handoffId: string;
|
|
||||||
repoRemote: string;
|
|
||||||
repoLocalPath: string;
|
|
||||||
branchName: string | null;
|
|
||||||
title: string | null;
|
|
||||||
task: string;
|
|
||||||
providerId: ProviderId;
|
|
||||||
agentType: AgentType | null;
|
|
||||||
explicitTitle: string | null;
|
|
||||||
explicitBranchName: string | null;
|
|
||||||
initialPrompt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InitializeCommand {
|
|
||||||
providerId?: ProviderId;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffActionCommand {
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffTabCommand {
|
|
||||||
tabId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffStatusSyncCommand {
|
|
||||||
sessionId: string;
|
|
||||||
status: "running" | "idle" | "error";
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchValueCommand {
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchSessionTitleCommand {
|
|
||||||
sessionId: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchSessionUnreadCommand {
|
|
||||||
sessionId: string;
|
|
||||||
unread: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchUpdateDraftCommand {
|
|
||||||
sessionId: string;
|
|
||||||
text: string;
|
|
||||||
attachments: Array<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchChangeModelCommand {
|
|
||||||
sessionId: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchSendMessageCommand {
|
|
||||||
sessionId: string;
|
|
||||||
text: string;
|
|
||||||
attachments: Array<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchCreateSessionCommand {
|
|
||||||
model?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffWorkbenchSessionCommand {
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handoff = actor({
|
|
||||||
db: handoffDb,
|
|
||||||
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
|
|
||||||
options: {
|
|
||||||
actionTimeout: 5 * 60_000,
|
|
||||||
},
|
|
||||||
createState: (_c, input: HandoffInput) => ({
|
|
||||||
workspaceId: input.workspaceId,
|
|
||||||
repoId: input.repoId,
|
|
||||||
handoffId: input.handoffId,
|
|
||||||
repoRemote: input.repoRemote,
|
|
||||||
repoLocalPath: input.repoLocalPath,
|
|
||||||
branchName: input.branchName,
|
|
||||||
title: input.title,
|
|
||||||
task: input.task,
|
|
||||||
providerId: input.providerId,
|
|
||||||
agentType: input.agentType,
|
|
||||||
explicitTitle: input.explicitTitle,
|
|
||||||
explicitBranchName: input.explicitBranchName,
|
|
||||||
initialPrompt: input.initialPrompt,
|
|
||||||
initialized: false,
|
|
||||||
previousStatus: null as string | null,
|
|
||||||
}),
|
|
||||||
actions: {
|
|
||||||
async initialize(c, cmd: InitializeCommand): Promise<HandoffRecord> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
return expectQueueResponse<HandoffRecord>(result);
|
|
||||||
},
|
|
||||||
|
|
||||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 30 * 60_000,
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
});
|
|
||||||
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
|
|
||||||
},
|
|
||||||
|
|
||||||
async switch(c): Promise<{ switchTarget: string }> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
const result = await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.switch"),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
|
||||||
},
|
|
||||||
|
|
||||||
async push(c, cmd?: HandoffActionCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 180_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async sync(c, cmd?: HandoffActionCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async merge(c, cmd?: HandoffActionCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async archive(c, cmd?: HandoffActionCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
void self
|
|
||||||
.send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 60_000,
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
c.log.warn({
|
|
||||||
msg: "archive command failed",
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async kill(c, cmd?: HandoffActionCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(c): Promise<HandoffRecord> {
|
|
||||||
return await getCurrentRecord({ db: c.db, state: c.state });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getWorkbench(c) {
|
|
||||||
return await getWorkbenchHandoff(c);
|
|
||||||
},
|
|
||||||
|
|
||||||
async markWorkbenchUnread(c): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.mark_unread"),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"), { value: input.value } satisfies HandoffWorkbenchValueCommand, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.rename_branch"), { value: input.value } satisfies HandoffWorkbenchValueCommand, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 5 * 60_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
const result = await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.create_session"),
|
|
||||||
{ ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 5 * 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return expectQueueResponse<{ tabId: string }>(result);
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.rename_session"),
|
|
||||||
{ sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"),
|
|
||||||
{ sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.update_draft"),
|
|
||||||
{
|
|
||||||
sessionId: input.tabId,
|
|
||||||
text: input.text,
|
|
||||||
attachments: input.attachments,
|
|
||||||
} satisfies HandoffWorkbenchUpdateDraftCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.change_model"),
|
|
||||||
{ sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.send_message"),
|
|
||||||
{
|
|
||||||
sessionId: input.tabId,
|
|
||||||
text: input.text,
|
|
||||||
attachments: input.attachments,
|
|
||||||
} satisfies HandoffWorkbenchSendMessageCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 10 * 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.stop_session"), { sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 5 * 60_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"), input, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 20_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.close_session"),
|
|
||||||
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 5 * 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async publishWorkbenchPr(c): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(
|
|
||||||
handoffWorkflowQueueName("handoff.command.workbench.publish_pr"),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
wait: true,
|
|
||||||
timeout: 10 * 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
|
||||||
const self = selfHandoff(c);
|
|
||||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.revert_file"), input, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 5 * 60_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
run: workflow(runHandoffWorkflow),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { HANDOFF_QUEUE_NAMES };
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
export const HANDOFF_QUEUE_NAMES = [
|
|
||||||
"handoff.command.initialize",
|
|
||||||
"handoff.command.provision",
|
|
||||||
"handoff.command.attach",
|
|
||||||
"handoff.command.switch",
|
|
||||||
"handoff.command.push",
|
|
||||||
"handoff.command.sync",
|
|
||||||
"handoff.command.merge",
|
|
||||||
"handoff.command.archive",
|
|
||||||
"handoff.command.kill",
|
|
||||||
"handoff.command.get",
|
|
||||||
"handoff.command.workbench.mark_unread",
|
|
||||||
"handoff.command.workbench.rename_handoff",
|
|
||||||
"handoff.command.workbench.rename_branch",
|
|
||||||
"handoff.command.workbench.create_session",
|
|
||||||
"handoff.command.workbench.rename_session",
|
|
||||||
"handoff.command.workbench.set_session_unread",
|
|
||||||
"handoff.command.workbench.update_draft",
|
|
||||||
"handoff.command.workbench.change_model",
|
|
||||||
"handoff.command.workbench.send_message",
|
|
||||||
"handoff.command.workbench.stop_session",
|
|
||||||
"handoff.command.workbench.sync_session_status",
|
|
||||||
"handoff.command.workbench.close_session",
|
|
||||||
"handoff.command.workbench.publish_pr",
|
|
||||||
"handoff.command.workbench.revert_file",
|
|
||||||
"handoff.status_sync.result",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function handoffWorkflowQueueName(name: string): string {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import migrations from "./migrations.js";
|
|
||||||
|
|
||||||
export const historyDb = actorSqliteDb({
|
|
||||||
actorName: "history",
|
|
||||||
schema,
|
|
||||||
migrations,
|
|
||||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import migrations from "./migrations.js";
|
|
||||||
|
|
||||||
export const projectDb = actorSqliteDb({
|
|
||||||
actorName: "project",
|
|
||||||
schema,
|
|
||||||
migrations,
|
|
||||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import migrations from "./migrations.js";
|
|
||||||
|
|
||||||
export const sandboxInstanceDb = actorSqliteDb({
|
|
||||||
actorName: "sandbox-instance",
|
|
||||||
schema,
|
|
||||||
migrations,
|
|
||||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import migrations from "./migrations.js";
|
|
||||||
|
|
||||||
export const workspaceDb = actorSqliteDb({
|
|
||||||
actorName: "workspace",
|
|
||||||
schema,
|
|
||||||
migrations,
|
|
||||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
CREATE TABLE `handoff_lookup` (
|
|
||||||
`handoff_id` text PRIMARY KEY NOT NULL,
|
|
||||||
`repo_id` text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
|
|
||||||
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
|
|
||||||
// Do not hand-edit this file.
|
|
||||||
|
|
||||||
const journal = {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
idx: 0,
|
|
||||||
when: 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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} 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 \`handoff_lookup\` (
|
|
||||||
\`handoff_id\` text PRIMARY KEY NOT NULL,
|
|
||||||
\`repo_id\` text NOT NULL
|
|
||||||
);
|
|
||||||
`,
|
|
||||||
} as const,
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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 handoffLookup = sqliteTable("handoff_lookup", {
|
|
||||||
handoffId: text("handoff_id").notNull().primaryKey(),
|
|
||||||
repoId: text("repo_id").notNull(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import { db as kvDrizzleDb } from "rivetkit/db/drizzle";
|
|
||||||
|
|
||||||
// Keep this file decoupled from RivetKit's internal type export paths.
|
|
||||||
// RivetKit consumes database providers structurally.
|
|
||||||
export interface RawAccess {
|
|
||||||
execute: (query: string, ...args: unknown[]) => Promise<unknown[]>;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DatabaseProviderContext {
|
|
||||||
actorId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DatabaseProvider<DB> = {
|
|
||||||
createClient: (ctx: DatabaseProviderContext) => Promise<DB>;
|
|
||||||
onMigrate: (client: DB) => void | Promise<void>;
|
|
||||||
onDestroy?: (client: DB) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ActorSqliteDbOptions<TSchema extends Record<string, unknown>> {
|
|
||||||
actorName: string;
|
|
||||||
schema?: TSchema;
|
|
||||||
migrations?: unknown;
|
|
||||||
migrationsFolderUrl: URL;
|
|
||||||
/**
|
|
||||||
* Override base directory for per-actor SQLite files.
|
|
||||||
*
|
|
||||||
* Default: `<cwd>/.openhandoff/backend/sqlite`
|
|
||||||
*/
|
|
||||||
baseDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function actorSqliteDb<TSchema extends Record<string, unknown>>(options: ActorSqliteDbOptions<TSchema>): DatabaseProvider<any & RawAccess> {
|
|
||||||
const isBunRuntime = typeof (globalThis as any).Bun !== "undefined" && typeof (process as any)?.versions?.bun === "string";
|
|
||||||
|
|
||||||
// Backend tests run in a Node-ish Vitest environment where `bun:sqlite` and
|
|
||||||
// Bun's sqlite-backed Drizzle driver are not supported.
|
|
||||||
//
|
|
||||||
// Additionally, RivetKit's KV-backed SQLite implementation currently has stability
|
|
||||||
// issues under Bun in this repo's setup (wa-sqlite runtime errors). Prefer Bun's
|
|
||||||
// native SQLite driver in production backend execution.
|
|
||||||
if (!isBunRuntime || process.env.VITEST || process.env.NODE_ENV === "test") {
|
|
||||||
return kvDrizzleDb({
|
|
||||||
schema: options.schema,
|
|
||||||
migrations: options.migrations,
|
|
||||||
}) as unknown as DatabaseProvider<any & RawAccess>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseDir = options.baseDir ?? join(process.cwd(), ".openhandoff", "backend", "sqlite");
|
|
||||||
const migrationsFolder = fileURLToPath(options.migrationsFolderUrl);
|
|
||||||
|
|
||||||
return {
|
|
||||||
createClient: async (ctx) => {
|
|
||||||
// Keep Bun-only module out of Vitest/Vite's static import graph.
|
|
||||||
const { Database } = await import(/* @vite-ignore */ "bun:sqlite");
|
|
||||||
const { drizzle } = await import("drizzle-orm/bun-sqlite");
|
|
||||||
|
|
||||||
const dir = join(baseDir, options.actorName);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
|
|
||||||
const dbPath = join(dir, `${ctx.actorId}.sqlite`);
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
sqlite.exec("PRAGMA journal_mode = WAL;");
|
|
||||||
sqlite.exec("PRAGMA foreign_keys = ON;");
|
|
||||||
|
|
||||||
const client = drizzle({
|
|
||||||
client: sqlite,
|
|
||||||
schema: options.schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.assign(client, {
|
|
||||||
execute: async (query: string, ...args: unknown[]) => {
|
|
||||||
const stmt = sqlite.query(query);
|
|
||||||
try {
|
|
||||||
return stmt.all(args as never) as unknown[];
|
|
||||||
} catch {
|
|
||||||
stmt.run(args as never);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close: async () => {
|
|
||||||
sqlite.close();
|
|
||||||
},
|
|
||||||
} satisfies RawAccess);
|
|
||||||
},
|
|
||||||
|
|
||||||
onMigrate: async (client) => {
|
|
||||||
const { migrate } = await import("drizzle-orm/bun-sqlite/migrator");
|
|
||||||
await migrate(client, {
|
|
||||||
migrationsFolder,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onDestroy: async (client) => {
|
|
||||||
await client.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { Hono } from "hono";
|
|
||||||
import { cors } from "hono/cors";
|
|
||||||
import { initActorRuntimeContext } from "./actors/context.js";
|
|
||||||
import { registry } from "./actors/index.js";
|
|
||||||
import { loadConfig } from "./config/backend.js";
|
|
||||||
import { createBackends, createNotificationService } from "./notifications/index.js";
|
|
||||||
import { createDefaultDriver } from "./driver.js";
|
|
||||||
import { createProviderRegistry } from "./providers/index.js";
|
|
||||||
|
|
||||||
export interface BackendStartOptions {
|
|
||||||
host?: string;
|
|
||||||
port?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
|
|
||||||
// sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth.
|
|
||||||
// Normalize to keep local dev + docker-compose simple.
|
|
||||||
if (!process.env.CODEX_API_KEY && process.env.OPENAI_API_KEY) {
|
|
||||||
process.env.CODEX_API_KEY = process.env.OPENAI_API_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = loadConfig();
|
|
||||||
config.backend.host = options.host ?? config.backend.host;
|
|
||||||
config.backend.port = options.port ?? config.backend.port;
|
|
||||||
|
|
||||||
// Allow docker-compose/dev environments to supply provider config via env vars
|
|
||||||
// instead of writing into the container's config.toml.
|
|
||||||
const envFirst = (...keys: string[]): string | undefined => {
|
|
||||||
for (const key of keys) {
|
|
||||||
const raw = process.env[key];
|
|
||||||
if (raw && raw.trim().length > 0) return raw.trim();
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
config.providers.daytona.endpoint = envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? config.providers.daytona.endpoint;
|
|
||||||
config.providers.daytona.apiKey = envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey;
|
|
||||||
|
|
||||||
const driver = createDefaultDriver();
|
|
||||||
const providers = createProviderRegistry(config, driver);
|
|
||||||
const backends = await createBackends(config.notify);
|
|
||||||
const notifications = createNotificationService(backends);
|
|
||||||
initActorRuntimeContext(config, providers, notifications, driver);
|
|
||||||
|
|
||||||
const inner = registry.serve();
|
|
||||||
|
|
||||||
// Wrap in a Hono app mounted at /api/rivet to serve on the backend port.
|
|
||||||
// Uses Bun.serve — cannot use @hono/node-server because it conflicts with
|
|
||||||
// RivetKit's internal Bun.serve manager server (Bun bug: mixing Node HTTP
|
|
||||||
// server and Bun.serve in the same process breaks Bun.serve's fetch handler).
|
|
||||||
const app = new Hono();
|
|
||||||
app.use(
|
|
||||||
"/api/rivet/*",
|
|
||||||
cors({
|
|
||||||
origin: "*",
|
|
||||||
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
|
|
||||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
||||||
exposeHeaders: ["Content-Type"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
app.use(
|
|
||||||
"/api/rivet",
|
|
||||||
cors({
|
|
||||||
origin: "*",
|
|
||||||
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
|
|
||||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
||||||
exposeHeaders: ["Content-Type"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const forward = async (c: any) => {
|
|
||||||
try {
|
|
||||||
// RivetKit serverless handler is configured with basePath `/api/rivet` by default.
|
|
||||||
return await inner.fetch(c.req.raw);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof URIError) {
|
|
||||||
return c.text("Bad Request: Malformed URI", 400);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
app.all("/api/rivet", forward);
|
|
||||||
app.all("/api/rivet/*", forward);
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
fetch: app.fetch,
|
|
||||||
hostname: config.backend.host,
|
|
||||||
port: config.backend.port,
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
|
||||||
server.stop();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
|
||||||
server.stop();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep process alive.
|
|
||||||
await new Promise<void>(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArg(flag: string): string | undefined {
|
|
||||||
const idx = process.argv.indexOf(flag);
|
|
||||||
if (idx < 0) return undefined;
|
|
||||||
return process.argv[idx + 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvPort(value: string | undefined): number | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const port = Number(value);
|
|
||||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const cmd = process.argv[2] ?? "start";
|
|
||||||
if (cmd !== "start") {
|
|
||||||
throw new Error(`Unsupported backend command: ${cmd}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = parseArg("--host") ?? process.env.HOST ?? process.env.HF_BACKEND_HOST;
|
|
||||||
const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT;
|
|
||||||
await startBackend({
|
|
||||||
host,
|
|
||||||
port: parseEnvPort(port),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
main().catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
||||||
console.error(message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../src/services/repo.js";
|
|
||||||
|
|
||||||
describe("normalizeRemoteUrl", () => {
|
|
||||||
test("accepts GitHub shorthand owner/repo", () => {
|
|
||||||
expect(normalizeRemoteUrl("rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts github.com/owner/repo without scheme", () => {
|
|
||||||
expect(normalizeRemoteUrl("github.com/rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("canonicalizes GitHub repo URLs without .git", () => {
|
|
||||||
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("canonicalizes GitHub non-clone URLs (e.g. /tree/main)", () => {
|
|
||||||
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff/tree/main")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not rewrite scp-style ssh remotes", () => {
|
|
||||||
expect(normalizeRemoteUrl("git@github.com:rivet-dev/openhandoff.git")).toBe("git@github.com:rivet-dev/openhandoff.git");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("repoIdFromRemote", () => {
|
|
||||||
test("repoId is stable across equivalent GitHub inputs", () => {
|
|
||||||
const a = repoIdFromRemote("rivet-dev/openhandoff");
|
|
||||||
const b = repoIdFromRemote("https://github.com/rivet-dev/openhandoff.git");
|
|
||||||
const c = repoIdFromRemote("https://github.com/rivet-dev/openhandoff/tree/main");
|
|
||||||
expect(a).toBe(b);
|
|
||||||
expect(b).toBe(c);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/inspector";
|
|
||||||
|
|
||||||
const targets = [
|
|
||||||
{ actorId: "2e443238457137bf", handoffId: "7df7656e-bbd2-4b8c-bf0f-30d4df2f619a" },
|
|
||||||
{ actorId: "0e53dd77ef06862f", handoffId: "0e01a31c-2dc1-4a1d-8ab0-9f0816359a85" },
|
|
||||||
{ actorId: "ea8c0e764c836e5f", handoffId: "cdc22436-4020-4f73-b3e7-7782fec29ae4" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function decodeAscii(u8) {
|
|
||||||
return new TextDecoder().decode(u8).replace(/[\x00-\x1F\x7F-\xFF]/g, ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
function locationToNames(entry, names) {
|
|
||||||
return entry.location.map((seg) => {
|
|
||||||
if (seg.tag === "WorkflowNameIndex") return names[seg.val] ?? `#${seg.val}`;
|
|
||||||
if (seg.tag === "WorkflowLoopIterationMarker") return `iter(${seg.val.iteration})`;
|
|
||||||
return seg.tag;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const t of targets) {
|
|
||||||
const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${t.actorId}.db`, { readonly: true });
|
|
||||||
const token = new TextDecoder().decode(db.query("SELECT value FROM kv WHERE hex(key)=?").get("03").value);
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${t.actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]);
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
const to = setTimeout(() => reject(new Error("timeout")), 15000);
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer);
|
|
||||||
const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data);
|
|
||||||
if (msg.body.tag !== "Init") return;
|
|
||||||
|
|
||||||
const wh = decodeWorkflowHistoryTransport(msg.body.val.workflowHistory);
|
|
||||||
const entryMetadata = wh.entryMetadata;
|
|
||||||
const enriched = wh.entries.map((e) => {
|
|
||||||
const meta = entryMetadata.get(e.id);
|
|
||||||
return {
|
|
||||||
id: e.id,
|
|
||||||
path: locationToNames(e, wh.nameRegistry).join("/"),
|
|
||||||
kind: e.kind.tag,
|
|
||||||
status: meta?.status ?? null,
|
|
||||||
error: meta?.error ?? null,
|
|
||||||
attempts: meta?.attempts ?? null,
|
|
||||||
entryError: e.kind.tag === "WorkflowStepEntry" ? (e.kind.val.error ?? null) : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501");
|
|
||||||
const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
handoffId: t.handoffId,
|
|
||||||
actorId: t.actorId,
|
|
||||||
wfState,
|
|
||||||
names: wh.nameRegistry,
|
|
||||||
entries: enriched,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
clearTimeout(to);
|
|
||||||
ws.close();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
|
||||||
clearTimeout(to);
|
|
||||||
reject(err);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
|
|
||||||
const db = new Database("/root/.local/share/openhandoff/rivetkit/databases/2e443238457137bf.db", { readonly: true });
|
|
||||||
const rows = db.query("SELECT hex(key) as k, value as v FROM kv WHERE hex(key) LIKE ? ORDER BY key").all("07%");
|
|
||||||
const out = rows.map((r) => {
|
|
||||||
const bytes = new Uint8Array(r.v);
|
|
||||||
const txt = new TextDecoder().decode(bytes).replace(/[\x00-\x1F\x7F-\xFF]/g, ".");
|
|
||||||
return { k: r.k, vlen: bytes.length, txt: txt.slice(0, 260) };
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(out, null, 2));
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { TO_CLIENT_VERSIONED, TO_SERVER_VERSIONED, CURRENT_VERSION, decodeWorkflowHistoryTransport } from "rivetkit/inspector";
|
|
||||||
import { decodeReadRangeWire } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/encoding.ts";
|
|
||||||
import { readRangeWireToOtlp } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/read-range.ts";
|
|
||||||
|
|
||||||
const actorId = "2e443238457137bf";
|
|
||||||
const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true });
|
|
||||||
const row = db.query("SELECT value FROM kv WHERE hex(key)=?").get("03");
|
|
||||||
const token = new TextDecoder().decode(row.value);
|
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]);
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
let sent = false;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
console.error("timeout");
|
|
||||||
process.exit(2);
|
|
||||||
}, 20000);
|
|
||||||
|
|
||||||
function send(body) {
|
|
||||||
const bytes = TO_SERVER_VERSIONED.serializeWithEmbeddedVersion({ body }, CURRENT_VERSION);
|
|
||||||
ws.send(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer);
|
|
||||||
const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data);
|
|
||||||
|
|
||||||
if (!sent && msg.body.tag === "Init") {
|
|
||||||
const init = msg.body.val;
|
|
||||||
const wh = decodeWorkflowHistoryTransport(init.workflowHistory);
|
|
||||||
const queueSize = Number(init.queueSize);
|
|
||||||
console.log(JSON.stringify({ tag: "InitSummary", queueSize, rpcs: init.rpcs, historyEntries: wh.entries.length, names: wh.nameRegistry }, null, 2));
|
|
||||||
|
|
||||||
send({ tag: "QueueRequest", val: { id: 1n, limit: 20n } });
|
|
||||||
send({ tag: "WorkflowHistoryRequest", val: { id: 2n } });
|
|
||||||
send({ tag: "TraceQueryRequest", val: { id: 3n, startMs: 0n, endMs: BigInt(Date.now()), limit: 2000n } });
|
|
||||||
sent = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.body.tag === "QueueResponse") {
|
|
||||||
const status = msg.body.val.status;
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
tag: "QueueResponse",
|
|
||||||
size: Number(status.size),
|
|
||||||
truncated: status.truncated,
|
|
||||||
messages: status.messages.map((m) => ({ id: Number(m.id), name: m.name, createdAtMs: Number(m.createdAtMs) })),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.body.tag === "WorkflowHistoryResponse") {
|
|
||||||
const wh = decodeWorkflowHistoryTransport(msg.body.val.history);
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{ tag: "WorkflowHistoryResponse", isWorkflowEnabled: msg.body.val.isWorkflowEnabled, entryCount: wh.entries.length, names: wh.nameRegistry },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.body.tag === "TraceQueryResponse") {
|
|
||||||
const wire = decodeReadRangeWire(new Uint8Array(msg.body.val.payload));
|
|
||||||
const otlp = readRangeWireToOtlp(wire, { attributes: [], droppedAttributesCount: 0 });
|
|
||||||
const spans = (((otlp?.resourceSpans ?? [])[0]?.scopeSpans ?? [])[0]?.spans ?? []).map((s) => ({ name: s.name, status: s.status?.code }));
|
|
||||||
console.log(JSON.stringify({ tag: "TraceQueryResponse", spanCount: spans.length, tail: spans.slice(-25) }, null, 2));
|
|
||||||
clearTimeout(timeout);
|
|
||||||
ws.close();
|
|
||||||
process.exit(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (e) => {
|
|
||||||
console.error("ws error", e);
|
|
||||||
clearTimeout(timeout);
|
|
||||||
process.exit(1);
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
|
|
||||||
const actorIds = [
|
|
||||||
"2e443238457137bf", // 7df...
|
|
||||||
"2b3fe1c099327eed", // 706...
|
|
||||||
"331b7f2a0cd19973", // 70c...
|
|
||||||
"329a70fc689f56ca", // 1f14...
|
|
||||||
"0e53dd77ef06862f", // 0e01...
|
|
||||||
"ea8c0e764c836e5f", // cdc error
|
|
||||||
];
|
|
||||||
|
|
||||||
function decodeAscii(u8) {
|
|
||||||
return new TextDecoder().decode(u8).replace(/[\x00-\x1F\x7F-\xFF]/g, ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const actorId of actorIds) {
|
|
||||||
const dbPath = `/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`;
|
|
||||||
const db = new Database(dbPath, { readonly: true });
|
|
||||||
|
|
||||||
const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501");
|
|
||||||
const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null;
|
|
||||||
|
|
||||||
const names = db
|
|
||||||
.query("SELECT value FROM kv WHERE hex(key) LIKE ? ORDER BY key")
|
|
||||||
.all("07150115%")
|
|
||||||
.map((r) => decodeAscii(new Uint8Array(r.value)));
|
|
||||||
|
|
||||||
const queueRows = db
|
|
||||||
.query("SELECT hex(key) as k, value FROM kv WHERE hex(key) LIKE ? ORDER BY key")
|
|
||||||
.all("05%")
|
|
||||||
.map((r) => ({
|
|
||||||
key: r.k,
|
|
||||||
preview: decodeAscii(new Uint8Array(r.value)).slice(0, 220),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const hasCreateSandboxStepName = names.includes("init-create-sandbox") || names.includes("init_create_sandbox");
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
actorId,
|
|
||||||
wfState,
|
|
||||||
hasCreateSandboxStepName,
|
|
||||||
names,
|
|
||||||
queue: queueRows,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/inspector";
|
|
||||||
import util from "node:util";
|
|
||||||
|
|
||||||
const actorId = "2e443238457137bf";
|
|
||||||
const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true });
|
|
||||||
const row = db.query("SELECT value FROM kv WHERE hex(key) = ?").get("03");
|
|
||||||
const token = new TextDecoder().decode(row.value);
|
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]);
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
const timeout = setTimeout(() => process.exit(2), 15000);
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer);
|
|
||||||
const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data);
|
|
||||||
const init = msg.body?.tag === "Init" ? msg.body.val : null;
|
|
||||||
if (!init) {
|
|
||||||
console.log("unexpected", util.inspect(msg, { depth: 4 }));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const decoded = decodeWorkflowHistoryTransport(init.workflowHistory);
|
|
||||||
console.log(util.inspect(decoded, { depth: 10, colors: false, compact: false, breakLength: 140 }));
|
|
||||||
clearTimeout(timeout);
|
|
||||||
ws.close();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
ws.onerror = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
process.exit(1);
|
|
||||||
};
|
|
||||||
|
|
@ -1,443 +0,0 @@
|
||||||
import {
|
|
||||||
MODEL_GROUPS,
|
|
||||||
buildInitialMockLayoutViewModel,
|
|
||||||
groupWorkbenchProjects,
|
|
||||||
nowMs,
|
|
||||||
providerAgent,
|
|
||||||
randomReply,
|
|
||||||
removeFileTreePath,
|
|
||||||
slugify,
|
|
||||||
uid,
|
|
||||||
} from "../workbench-model.js";
|
|
||||||
import type {
|
|
||||||
HandoffWorkbenchAddTabResponse,
|
|
||||||
HandoffWorkbenchChangeModelInput,
|
|
||||||
HandoffWorkbenchCreateHandoffInput,
|
|
||||||
HandoffWorkbenchCreateHandoffResponse,
|
|
||||||
HandoffWorkbenchDiffInput,
|
|
||||||
HandoffWorkbenchRenameInput,
|
|
||||||
HandoffWorkbenchRenameSessionInput,
|
|
||||||
HandoffWorkbenchSelectInput,
|
|
||||||
HandoffWorkbenchSetSessionUnreadInput,
|
|
||||||
HandoffWorkbenchSendMessageInput,
|
|
||||||
HandoffWorkbenchSnapshot,
|
|
||||||
HandoffWorkbenchTabInput,
|
|
||||||
HandoffWorkbenchUpdateDraftInput,
|
|
||||||
WorkbenchAgentTab as AgentTab,
|
|
||||||
WorkbenchHandoff as Handoff,
|
|
||||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
|
||||||
} from "@openhandoff/shared";
|
|
||||||
import type { HandoffWorkbenchClient } from "../workbench-client.js";
|
|
||||||
|
|
||||||
function buildTranscriptEvent(params: {
|
|
||||||
sessionId: string;
|
|
||||||
sender: "client" | "agent";
|
|
||||||
createdAt: number;
|
|
||||||
payload: unknown;
|
|
||||||
eventIndex: number;
|
|
||||||
}): TranscriptEvent {
|
|
||||||
return {
|
|
||||||
id: uid(),
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
sender: params.sender,
|
|
||||||
createdAt: params.createdAt,
|
|
||||||
payload: params.payload,
|
|
||||||
connectionId: "mock-connection",
|
|
||||||
eventIndex: params.eventIndex,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|
||||||
private snapshot = buildInitialMockLayoutViewModel();
|
|
||||||
private listeners = new Set<() => void>();
|
|
||||||
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
getSnapshot(): HandoffWorkbenchSnapshot {
|
|
||||||
return this.snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(listener: () => void): () => void {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
|
|
||||||
const id = uid();
|
|
||||||
const tabId = `session-${id}`;
|
|
||||||
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
|
|
||||||
if (!repo) {
|
|
||||||
throw new Error(`Cannot create mock handoff for unknown repo ${input.repoId}`);
|
|
||||||
}
|
|
||||||
const nextHandoff: Handoff = {
|
|
||||||
id,
|
|
||||||
repoId: repo.id,
|
|
||||||
title: input.title?.trim() || "New Handoff",
|
|
||||||
status: "new",
|
|
||||||
repoName: repo.label,
|
|
||||||
updatedAtMs: nowMs(),
|
|
||||||
branch: input.branch?.trim() || null,
|
|
||||||
pullRequest: null,
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
id: tabId,
|
|
||||||
sessionId: tabId,
|
|
||||||
sessionName: "Session 1",
|
|
||||||
agent: providerAgent(
|
|
||||||
MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude",
|
|
||||||
),
|
|
||||||
model: input.model ?? "claude-sonnet-4",
|
|
||||||
status: "idle",
|
|
||||||
thinkingSinceMs: null,
|
|
||||||
unread: false,
|
|
||||||
created: false,
|
|
||||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
|
||||||
transcript: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
fileChanges: [],
|
|
||||||
diffs: {},
|
|
||||||
fileTree: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateState((current) => ({
|
|
||||||
...current,
|
|
||||||
handoffs: [nextHandoff, ...current.handoffs],
|
|
||||||
}));
|
|
||||||
return { handoffId: id, tabId };
|
|
||||||
}
|
|
||||||
|
|
||||||
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => {
|
|
||||||
const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null;
|
|
||||||
if (!targetTab) {
|
|
||||||
return handoff;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...handoff,
|
|
||||||
tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`);
|
|
||||||
}
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`);
|
|
||||||
}
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
|
|
||||||
const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1;
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => ({
|
|
||||||
...handoff,
|
|
||||||
updatedAtMs: nowMs(),
|
|
||||||
pullRequest: { number: nextPrNumber, status: "ready" },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => {
|
|
||||||
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
|
|
||||||
const nextDiffs = { ...handoff.diffs };
|
|
||||||
delete nextDiffs[input.path];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...handoff,
|
|
||||||
fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path),
|
|
||||||
diffs: nextDiffs,
|
|
||||||
fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
|
||||||
this.assertTab(input.handoffId, input.tabId);
|
|
||||||
this.updateHandoff(input.handoffId, (handoff) => ({
|
|
||||||
...handoff,
|
|
||||||
updatedAtMs: nowMs(),
|
|
||||||
tabs: handoff.tabs.map((tab) =>
|
|
||||||
tab.id === input.tabId
|
|
||||||
? {
|
|
||||||
...tab,
|
|
||||||
draft: {
|
|
||||||
text: input.text,
|
|
||||||
attachments: input.attachments,
|
|
||||||
updatedAtMs: nowMs(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: tab,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
|
||||||
const text = input.text.trim();
|
|
||||||
if (!text) {
|
|
||||||
throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.assertTab(input.handoffId, input.tabId);
|
|
||||||
const startedAtMs = nowMs();
|
|
||||||
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
|
||||||
const isFirstOnHandoff = currentHandoff.status === "new";
|
|
||||||
const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title;
|
|
||||||
const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch;
|
|
||||||
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
|
|
||||||
const userEvent = buildTranscriptEvent({
|
|
||||||
sessionId: input.tabId,
|
|
||||||
sender: "client",
|
|
||||||
createdAt: startedAtMs,
|
|
||||||
eventIndex: candidateEventIndex(currentHandoff, input.tabId),
|
|
||||||
payload: {
|
|
||||||
method: "session/prompt",
|
|
||||||
params: {
|
|
||||||
prompt: userMessageLines.map((line) => ({ type: "text", text: line })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentHandoff,
|
|
||||||
title: newTitle,
|
|
||||||
branch: newBranch,
|
|
||||||
status: "running",
|
|
||||||
updatedAtMs: startedAtMs,
|
|
||||||
tabs: currentHandoff.tabs.map((candidate) =>
|
|
||||||
candidate.id === input.tabId
|
|
||||||
? {
|
|
||||||
...candidate,
|
|
||||||
created: true,
|
|
||||||
status: "running",
|
|
||||||
unread: false,
|
|
||||||
thinkingSinceMs: startedAtMs,
|
|
||||||
draft: { text: "", attachments: [], updatedAtMs: startedAtMs },
|
|
||||||
transcript: [...candidate.transcript, userEvent],
|
|
||||||
}
|
|
||||||
: candidate,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingTimer = this.pendingTimers.get(input.tabId);
|
|
||||||
if (existingTimer) {
|
|
||||||
clearTimeout(existingTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const handoff = this.requireHandoff(input.handoffId);
|
|
||||||
const replyTab = this.requireTab(handoff, input.tabId);
|
|
||||||
const completedAtMs = nowMs();
|
|
||||||
const replyEvent = buildTranscriptEvent({
|
|
||||||
sessionId: input.tabId,
|
|
||||||
sender: "agent",
|
|
||||||
createdAt: completedAtMs,
|
|
||||||
eventIndex: candidateEventIndex(handoff, input.tabId),
|
|
||||||
payload: {
|
|
||||||
result: {
|
|
||||||
text: randomReply(),
|
|
||||||
durationMs: completedAtMs - startedAtMs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
|
||||||
const updatedTabs = currentHandoff.tabs.map((candidate) => {
|
|
||||||
if (candidate.id !== input.tabId) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...candidate,
|
|
||||||
status: "idle" as const,
|
|
||||||
thinkingSinceMs: null,
|
|
||||||
unread: true,
|
|
||||||
transcript: [...candidate.transcript, replyEvent],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentHandoff,
|
|
||||||
updatedAtMs: completedAtMs,
|
|
||||||
tabs: updatedTabs,
|
|
||||||
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pendingTimers.delete(input.tabId);
|
|
||||||
}, 2_500);
|
|
||||||
|
|
||||||
this.pendingTimers.set(input.tabId, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
|
|
||||||
this.assertTab(input.handoffId, input.tabId);
|
|
||||||
const existing = this.pendingTimers.get(input.tabId);
|
|
||||||
if (existing) {
|
|
||||||
clearTimeout(existing);
|
|
||||||
this.pendingTimers.delete(input.tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
|
||||||
const updatedTabs = currentHandoff.tabs.map((candidate) =>
|
|
||||||
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
|
|
||||||
);
|
|
||||||
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentHandoff,
|
|
||||||
updatedAtMs: nowMs(),
|
|
||||||
tabs: updatedTabs,
|
|
||||||
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
|
||||||
...currentHandoff,
|
|
||||||
tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
|
||||||
const title = input.title.trim();
|
|
||||||
if (!title) {
|
|
||||||
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
|
|
||||||
}
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
|
||||||
...currentHandoff,
|
|
||||||
tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
|
||||||
if (currentHandoff.tabs.length <= 1) {
|
|
||||||
return currentHandoff;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentHandoff,
|
|
||||||
tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
|
|
||||||
this.assertHandoff(input.handoffId);
|
|
||||||
const nextTab: AgentTab = {
|
|
||||||
id: uid(),
|
|
||||||
sessionId: null,
|
|
||||||
sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`,
|
|
||||||
agent: "Claude",
|
|
||||||
model: "claude-sonnet-4",
|
|
||||||
status: "idle",
|
|
||||||
thinkingSinceMs: null,
|
|
||||||
unread: false,
|
|
||||||
created: false,
|
|
||||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
|
||||||
transcript: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
|
||||||
...currentHandoff,
|
|
||||||
updatedAtMs: nowMs(),
|
|
||||||
tabs: [...currentHandoff.tabs, nextTab],
|
|
||||||
}));
|
|
||||||
return { tabId: nextTab.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
|
||||||
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
|
|
||||||
if (!group) {
|
|
||||||
throw new Error(`Unable to resolve model provider for ${input.model}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
|
||||||
...currentHandoff,
|
|
||||||
tabs: currentHandoff.tabs.map((candidate) =>
|
|
||||||
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void {
|
|
||||||
const nextSnapshot = updater(this.snapshot);
|
|
||||||
this.snapshot = {
|
|
||||||
...nextSnapshot,
|
|
||||||
projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
|
|
||||||
};
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void {
|
|
||||||
this.assertHandoff(handoffId);
|
|
||||||
this.updateState((current) => ({
|
|
||||||
...current,
|
|
||||||
handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private notify(): void {
|
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertHandoff(handoffId: string): void {
|
|
||||||
this.requireHandoff(handoffId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertTab(handoffId: string, tabId: string): void {
|
|
||||||
const handoff = this.requireHandoff(handoffId);
|
|
||||||
this.requireTab(handoff, tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireHandoff(handoffId: string): Handoff {
|
|
||||||
const handoff = this.snapshot.handoffs.find((candidate) => candidate.id === handoffId);
|
|
||||||
if (!handoff) {
|
|
||||||
throw new Error(`Unable to find mock handoff ${handoffId}`);
|
|
||||||
}
|
|
||||||
return handoff;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireTab(handoff: Handoff, tabId: string): AgentTab {
|
|
||||||
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
|
|
||||||
if (!tab) {
|
|
||||||
throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`);
|
|
||||||
}
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function candidateEventIndex(handoff: Handoff, tabId: string): number {
|
|
||||||
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
|
|
||||||
return (tab?.transcript.length ?? 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null;
|
|
||||||
|
|
||||||
export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient {
|
|
||||||
if (!sharedMockWorkbenchClient) {
|
|
||||||
sharedMockWorkbenchClient = new MockWorkbenchStore();
|
|
||||||
}
|
|
||||||
return sharedMockWorkbenchClient;
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import type {
|
|
||||||
HandoffWorkbenchAddTabResponse,
|
|
||||||
HandoffWorkbenchChangeModelInput,
|
|
||||||
HandoffWorkbenchCreateHandoffInput,
|
|
||||||
HandoffWorkbenchCreateHandoffResponse,
|
|
||||||
HandoffWorkbenchDiffInput,
|
|
||||||
HandoffWorkbenchRenameInput,
|
|
||||||
HandoffWorkbenchRenameSessionInput,
|
|
||||||
HandoffWorkbenchSelectInput,
|
|
||||||
HandoffWorkbenchSetSessionUnreadInput,
|
|
||||||
HandoffWorkbenchSendMessageInput,
|
|
||||||
HandoffWorkbenchSnapshot,
|
|
||||||
HandoffWorkbenchTabInput,
|
|
||||||
HandoffWorkbenchUpdateDraftInput,
|
|
||||||
} from "@openhandoff/shared";
|
|
||||||
import type { BackendClient } from "./backend-client.js";
|
|
||||||
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
|
|
||||||
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
|
|
||||||
|
|
||||||
export type HandoffWorkbenchClientMode = "mock" | "remote";
|
|
||||||
|
|
||||||
export interface CreateHandoffWorkbenchClientOptions {
|
|
||||||
mode: HandoffWorkbenchClientMode;
|
|
||||||
backend?: BackendClient;
|
|
||||||
workspaceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HandoffWorkbenchClient {
|
|
||||||
getSnapshot(): HandoffWorkbenchSnapshot;
|
|
||||||
subscribe(listener: () => void): () => void;
|
|
||||||
createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
|
|
||||||
markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void>;
|
|
||||||
renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void>;
|
|
||||||
renameBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
|
|
||||||
archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
|
|
||||||
publishPr(input: HandoffWorkbenchSelectInput): Promise<void>;
|
|
||||||
revertFile(input: HandoffWorkbenchDiffInput): Promise<void>;
|
|
||||||
updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
|
|
||||||
sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
|
|
||||||
stopAgent(input: HandoffWorkbenchTabInput): Promise<void>;
|
|
||||||
setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void>;
|
|
||||||
renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void>;
|
|
||||||
closeTab(input: HandoffWorkbenchTabInput): Promise<void>;
|
|
||||||
addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse>;
|
|
||||||
changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createHandoffWorkbenchClient(options: CreateHandoffWorkbenchClientOptions): HandoffWorkbenchClient {
|
|
||||||
if (options.mode === "mock") {
|
|
||||||
return getSharedMockWorkbenchClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.backend) {
|
|
||||||
throw new Error("Remote handoff workbench client requires a backend client");
|
|
||||||
}
|
|
||||||
if (!options.workspaceId) {
|
|
||||||
throw new Error("Remote handoff workbench client requires a workspace id");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createRemoteWorkbenchClient({
|
|
||||||
backend: options.backend,
|
|
||||||
workspaceId: options.workspaceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import { useEffect } from "react";
|
|
||||||
import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client";
|
|
||||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
|
||||||
import { MockLayout } from "../components/mock-layout";
|
|
||||||
import { defaultWorkspaceId } from "../lib/env";
|
|
||||||
import { handoffWorkbenchClient } from "../lib/workbench";
|
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
|
||||||
component: RootLayout,
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/",
|
|
||||||
component: () => <Navigate to="/workspaces/$workspaceId" params={{ workspaceId: defaultWorkspaceId }} replace />,
|
|
||||||
});
|
|
||||||
|
|
||||||
const workspaceRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/workspaces/$workspaceId",
|
|
||||||
component: WorkspaceLayoutRoute,
|
|
||||||
});
|
|
||||||
|
|
||||||
const workspaceIndexRoute = createRoute({
|
|
||||||
getParentRoute: () => workspaceRoute,
|
|
||||||
path: "/",
|
|
||||||
component: WorkspaceRoute,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handoffRoute = createRoute({
|
|
||||||
getParentRoute: () => workspaceRoute,
|
|
||||||
path: "handoffs/$handoffId",
|
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
|
||||||
sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined,
|
|
||||||
}),
|
|
||||||
component: HandoffRoute,
|
|
||||||
});
|
|
||||||
|
|
||||||
const repoRoute = createRoute({
|
|
||||||
getParentRoute: () => workspaceRoute,
|
|
||||||
path: "repos/$repoId",
|
|
||||||
component: RepoRoute,
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute])]);
|
|
||||||
|
|
||||||
export const router = createRouter({ routeTree });
|
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
|
||||||
interface Register {
|
|
||||||
router: typeof router;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceLayoutRoute() {
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceRoute() {
|
|
||||||
const { workspaceId } = workspaceRoute.useParams();
|
|
||||||
useEffect(() => {
|
|
||||||
setFrontendErrorContext({
|
|
||||||
workspaceId,
|
|
||||||
handoffId: undefined,
|
|
||||||
});
|
|
||||||
}, [workspaceId]);
|
|
||||||
return <MockLayout workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HandoffRoute() {
|
|
||||||
const { workspaceId, handoffId } = handoffRoute.useParams();
|
|
||||||
const { sessionId } = handoffRoute.useSearch();
|
|
||||||
useEffect(() => {
|
|
||||||
setFrontendErrorContext({
|
|
||||||
workspaceId,
|
|
||||||
handoffId,
|
|
||||||
repoId: undefined,
|
|
||||||
});
|
|
||||||
}, [handoffId, workspaceId]);
|
|
||||||
return <MockLayout workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepoRoute() {
|
|
||||||
const { workspaceId, repoId } = repoRoute.useParams();
|
|
||||||
useEffect(() => {
|
|
||||||
setFrontendErrorContext({
|
|
||||||
workspaceId,
|
|
||||||
handoffId: undefined,
|
|
||||||
repoId,
|
|
||||||
});
|
|
||||||
}, [repoId, workspaceId]);
|
|
||||||
const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find((handoff) => handoff.repoId === repoId)?.id;
|
|
||||||
if (!activeHandoffId) {
|
|
||||||
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Navigate
|
|
||||||
to="/workspaces/$workspaceId/handoffs/$handoffId"
|
|
||||||
params={{
|
|
||||||
workspaceId,
|
|
||||||
handoffId: activeHandoffId,
|
|
||||||
}}
|
|
||||||
search={{ sessionId: undefined }}
|
|
||||||
replace
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootLayout() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RouteContextSync />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteContextSync() {
|
|
||||||
const location = useRouterState({
|
|
||||||
select: (state) => state.location,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFrontendErrorContext({
|
|
||||||
route: `${location.pathname}${location.search}${location.hash}`,
|
|
||||||
});
|
|
||||||
}, [location.hash, location.pathname, location.search]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -27,27 +27,23 @@ Use `pnpm` workspaces and Turborepo.
|
||||||
- `packages/cli` is fully disabled for active development.
|
- `packages/cli` is fully disabled for active development.
|
||||||
- Do not implement new behavior in `packages/cli` unless explicitly requested.
|
- 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`.
|
- Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`.
|
||||||
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@openhandoff/cli`.
|
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@sandbox-agent/foundry-cli`.
|
||||||
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
|
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
|
||||||
|
|
||||||
## Common Commands
|
## 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`
|
- Install deps: `pnpm install`
|
||||||
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
|
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
|
||||||
- Start the full dev stack: `just factory-dev`
|
- Start the full dev stack: `just foundry-dev`
|
||||||
- Start the local production-build preview stack: `just factory-preview`
|
- Start the local production-build preview stack: `just foundry-preview`
|
||||||
- Start only the backend locally: `just factory-backend-start`
|
- Start only the backend locally: `just foundry-backend-start`
|
||||||
- Start only the frontend locally: `pnpm --filter @openhandoff/frontend dev`
|
- Start only the frontend locally: `pnpm --filter @sandbox-agent/foundry-frontend dev`
|
||||||
- Start the frontend against the mock workbench client: `OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/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 factory-dev-down`
|
- Stop the compose dev stack: `just foundry-dev-down`
|
||||||
- Tail compose logs: `just factory-dev-logs`
|
- Tail compose logs: `just foundry-dev-logs`
|
||||||
- Stop the preview stack: `just factory-preview-down`
|
- Stop the preview stack: `just foundry-preview-down`
|
||||||
- Tail preview logs: `just factory-preview-logs`
|
- Tail preview logs: `just foundry-preview-logs`
|
||||||
|
|
||||||
## Local Env
|
|
||||||
|
|
||||||
- For local The Foundry dev server setup, keep a personal env copy at `~/misc/the-foundry.env`.
|
|
||||||
- To run the dev server from this workspace, copy that content into the repo root `.env`. Root `.env` is gitignored in this repo, so keep local secrets there and do not commit them.
|
|
||||||
|
|
||||||
## Frontend + Client Boundary
|
## Frontend + Client Boundary
|
||||||
|
|
||||||
|
|
@ -85,12 +81,12 @@ For all Rivet/RivetKit implementation:
|
||||||
2. SQLite is **per actor instance** (per actor key), not a shared backend-global database:
|
2. SQLite is **per actor instance** (per actor key), not a shared backend-global database:
|
||||||
- Each actor instance gets its own SQLite DB.
|
- Each actor instance gets its own SQLite DB.
|
||||||
- Schema design should assume a single actor instance owns the entire DB.
|
- Schema design should assume a single actor instance owns the entire DB.
|
||||||
- Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
|
- 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 `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys.
|
- 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`).
|
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.
|
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.
|
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/handoff/rivet-checkout`
|
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/task/rivet-checkout`
|
||||||
- Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit`
|
- 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.
|
- 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:
|
6. Before using a local checkout, build RivetKit in the rivet repo:
|
||||||
|
|
@ -108,7 +104,7 @@ For all Rivet/RivetKit implementation:
|
||||||
curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint'
|
curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint'
|
||||||
```
|
```
|
||||||
- List actors:
|
- List actors:
|
||||||
- `GET {manager}/actors?name=handoff`
|
- `GET {manager}/actors?name=task`
|
||||||
- Inspector endpoints (path prefix: `/gateway/{actorId}/inspector`):
|
- Inspector endpoints (path prefix: `/gateway/{actorId}/inspector`):
|
||||||
- `GET /state`
|
- `GET /state`
|
||||||
- `PATCH /state`
|
- `PATCH /state`
|
||||||
|
|
@ -122,12 +118,12 @@ For all Rivet/RivetKit implementation:
|
||||||
- Auth:
|
- Auth:
|
||||||
- Production: send `Authorization: Bearer $RIVET_INSPECTOR_TOKEN`.
|
- Production: send `Authorization: Bearer $RIVET_INSPECTOR_TOKEN`.
|
||||||
- Development: auth can be skipped when no inspector token is configured.
|
- Development: auth can be skipped when no inspector token is configured.
|
||||||
- Handoff workflow quick inspect:
|
- Task workflow quick inspect:
|
||||||
```bash
|
```bash
|
||||||
MGR="$(curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint')"
|
MGR="$(curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint')"
|
||||||
HID="7df7656e-bbd2-4b8c-bf0f-30d4df2f619a"
|
HID="7df7656e-bbd2-4b8c-bf0f-30d4df2f619a"
|
||||||
AID="$(curl -sS "$MGR/actors?name=handoff" \
|
AID="$(curl -sS "$MGR/actors?name=task" \
|
||||||
| jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/handoff/\($hid)")) | .actor_id' \
|
| jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/task/\($hid)")) | .actor_id' \
|
||||||
| head -n1)"
|
| head -n1)"
|
||||||
curl -sS "$MGR/gateway/$AID/inspector/workflow-history" | jq .
|
curl -sS "$MGR/gateway/$AID/inspector/workflow-history" | jq .
|
||||||
curl -sS "$MGR/gateway/$AID/inspector/summary" | jq .
|
curl -sS "$MGR/gateway/$AID/inspector/summary" | jq .
|
||||||
|
|
@ -140,11 +136,11 @@ For all Rivet/RivetKit implementation:
|
||||||
- Workspace resolution order: `--workspace` flag -> config default -> `"default"`.
|
- Workspace resolution order: `--workspace` flag -> config default -> `"default"`.
|
||||||
- `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator).
|
- `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator).
|
||||||
- Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`).
|
- Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`).
|
||||||
- CLI/TUI/GUI must use `@openhandoff/client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`.
|
- 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).
|
- 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.
|
- 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.
|
- Keep strict single-writer ownership: each table/row has exactly one actor writer.
|
||||||
- Parent actors (`workspace`, `project`, `handoff`, `history`, `sandbox-instance`) use command-only loops with no timeout.
|
- 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.
|
- Periodic syncing lives in dedicated child actors with one timeout cadence each.
|
||||||
- Actor handle policy:
|
- Actor handle policy:
|
||||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||||
|
|
@ -152,13 +148,13 @@ For all Rivet/RivetKit implementation:
|
||||||
- Use create semantics only on explicit provisioning/create paths where creating a new actor instance is intended.
|
- 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.
|
- `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.
|
- For long-lived cross-actor links (for example sandbox/session runtime access), persist actor identity (`actorId`) and keep a fallback lookup path by actor id.
|
||||||
- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/openhandoff/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed).
|
- 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.
|
- 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:
|
- Workflow history divergence policy:
|
||||||
- Production: never auto-delete actor state to resolve `HistoryDivergedError`; ship explicit workflow migrations (`ctx.removed(...)`, step compatibility).
|
- 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.
|
- Development: manual local state reset is allowed as an operator recovery path when migrations are not yet available.
|
||||||
- Storage rule of thumb:
|
- Storage rule of thumb:
|
||||||
- Put simple metadata in `c.state` (KV state): small scalars and identifiers like `{ handoffId }`, `{ repoId }`, booleans, counters, timestamps, status strings.
|
- 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`.
|
- If it grows beyond trivial (arrays, maps, histories, query/filter needs, relational consistency), use SQLite + Drizzle in `c.db`.
|
||||||
|
|
||||||
## Testing Policy
|
## Testing Policy
|
||||||
|
|
@ -168,7 +164,6 @@ For all Rivet/RivetKit implementation:
|
||||||
- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`.
|
- 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.
|
- 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.
|
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
|
||||||
- Current org test repo: `rivet-dev/sandbox-agent-testing` (`https://github.com/rivet-dev/sandbox-agent-testing`).
|
|
||||||
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
- 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.
|
- 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.
|
- Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E.
|
||||||
|
|
@ -176,7 +171,7 @@ For all Rivet/RivetKit implementation:
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
- Keep config path at `~/.config/openhandoff/config.toml`.
|
- Keep config path at `~/.config/foundry/config.toml`.
|
||||||
- Evolve properties in place; do not move config location.
|
- Evolve properties in place; do not move config location.
|
||||||
|
|
||||||
## Project Guidance
|
## Project Guidance
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
1. Clone:
|
1. Clone:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/rivet-dev/openhandoff.git
|
git clone https://github.com/rivet-dev/sandbox-agent.git
|
||||||
cd openhandoff
|
cd sandbox-agent/foundry
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
@ -35,7 +35,7 @@ Build local RivetKit before backend changes that depend on Rivet internals:
|
||||||
cd ../rivet
|
cd ../rivet
|
||||||
pnpm build -F rivetkit
|
pnpm build -F rivetkit
|
||||||
|
|
||||||
cd /path/to/openhandoff
|
cd /path/to/sandbox-agent/foundry
|
||||||
just sync-rivetkit
|
just sync-rivetkit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -54,11 +54,11 @@ pnpm -w test
|
||||||
Start the dev backend (hot reload via `bun --watch`) and Vite frontend via Docker Compose:
|
Start the dev backend (hot reload via `bun --watch`) and Vite frontend via Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just factory-dev
|
just foundry-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop it:
|
Stop it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just factory-dev-down
|
just foundry-dev-down
|
||||||
```
|
```
|
||||||
|
|
@ -22,19 +22,19 @@ COPY packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json packages/rivetki
|
||||||
COPY packages/rivetkit-vendor/runner/package.json packages/rivetkit-vendor/runner/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/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
|
COPY packages/rivetkit-vendor/virtual-websocket/package.json packages/rivetkit-vendor/virtual-websocket/package.json
|
||||||
RUN pnpm fetch --frozen-lockfile --filter @openhandoff/backend...
|
RUN pnpm fetch --frozen-lockfile --filter @sandbox-agent/foundry-backend...
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY --from=deps /pnpm/store /pnpm/store
|
COPY --from=deps /pnpm/store /pnpm/store
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --frozen-lockfile --prefer-offline --filter @openhandoff/backend...
|
RUN pnpm install --frozen-lockfile --prefer-offline --filter @sandbox-agent/foundry-backend...
|
||||||
RUN pnpm --filter @openhandoff/shared build
|
RUN pnpm --filter @sandbox-agent/foundry-shared build
|
||||||
RUN pnpm --filter @openhandoff/backend build
|
RUN pnpm --filter @sandbox-agent/foundry-backend build
|
||||||
RUN pnpm --filter @openhandoff/backend deploy --prod --legacy /out
|
RUN pnpm --filter @sandbox-agent/foundry-backend deploy --prod --legacy /out
|
||||||
|
|
||||||
FROM oven/bun:1.2 AS runtime
|
FROM oven/bun:1.2 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOME=/home/handoff
|
ENV HOME=/home/task
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
|
@ -43,11 +43,11 @@ RUN apt-get update \
|
||||||
gh \
|
gh \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN addgroup --system --gid 1001 handoff \
|
RUN addgroup --system --gid 1001 task \
|
||||||
&& adduser --system --uid 1001 --home /home/handoff --ingroup handoff handoff \
|
&& adduser --system --uid 1001 --home /home/task --ingroup task task \
|
||||||
&& mkdir -p /home/handoff \
|
&& mkdir -p /home/task \
|
||||||
&& chown -R handoff:handoff /home/handoff /app
|
&& chown -R task:task /home/task /app
|
||||||
COPY --from=build /out ./
|
COPY --from=build /out ./
|
||||||
USER handoff
|
USER task
|
||||||
EXPOSE 7741
|
EXPOSE 7741
|
||||||
CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"]
|
CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"]
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# OpenHandoff
|
# Foundry
|
||||||
|
|
||||||
TypeScript workspace handoff system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI.
|
TypeScript workspace task system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI.
|
||||||
|
|
||||||
**Documentation**: [openhandoff.dev](https://openhandoff.dev)
|
**Documentation**: see `../docs/` in the repository root
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
90
foundry/compose.dev.yaml
Normal file
90
foundry/compose.dev.yaml
Normal 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: {}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
name: openhandoff-preview
|
name: foundry-preview
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: quebec/docker/backend.preview.Dockerfile
|
dockerfile: foundry/docker/backend.preview.Dockerfile
|
||||||
image: openhandoff-backend-preview
|
image: foundry-backend-preview
|
||||||
environment:
|
environment:
|
||||||
HF_BACKEND_HOST: "0.0.0.0"
|
HF_BACKEND_HOST: "0.0.0.0"
|
||||||
HF_BACKEND_PORT: "7841"
|
HF_BACKEND_PORT: "7841"
|
||||||
HF_RIVET_MANAGER_PORT: "8850"
|
HF_RIVET_MANAGER_PORT: "8850"
|
||||||
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
|
RIVETKIT_STORAGE_PATH: "/root/.local/share/foundry/rivetkit"
|
||||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||||
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
|
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
|
||||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||||
|
|
@ -26,19 +26,19 @@ services:
|
||||||
- "8850:8850"
|
- "8850:8850"
|
||||||
volumes:
|
volumes:
|
||||||
- "${HOME}/.codex:/root/.codex"
|
- "${HOME}/.codex:/root/.codex"
|
||||||
- "openhandoff_preview_git_repos:/root/.local/share/openhandoff/repos"
|
- "foundry_preview_git_repos:/root/.local/share/foundry/repos"
|
||||||
- "openhandoff_preview_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
|
- "foundry_preview_rivetkit_storage:/root/.local/share/foundry/rivetkit"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: quebec/docker/frontend.preview.Dockerfile
|
dockerfile: foundry/docker/frontend.preview.Dockerfile
|
||||||
image: openhandoff-frontend-preview
|
image: foundry-frontend-preview
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "4273:4273"
|
- "4273:4273"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
openhandoff_preview_git_repos: {}
|
foundry_preview_git_repos: {}
|
||||||
openhandoff_preview_rivetkit_storage: {}
|
foundry_preview_rivetkit_storage: {}
|
||||||
|
|
@ -39,4 +39,4 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @openhandoff/backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
|
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"]
|
||||||
|
|
@ -42,8 +42,8 @@ COPY quebec /workspace/quebec
|
||||||
COPY rivet-checkout /workspace/rivet-checkout
|
COPY rivet-checkout /workspace/rivet-checkout
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm --filter @openhandoff/shared build
|
RUN pnpm --filter @sandbox-agent/foundry-shared build
|
||||||
RUN pnpm --filter @openhandoff/client build
|
RUN pnpm --filter @sandbox-agent/foundry-client build
|
||||||
RUN pnpm --filter @openhandoff/backend 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"]
|
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"]
|
||||||
|
|
@ -8,4 +8,4 @@ RUN npm install -g pnpm@10.28.2
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @openhandoff/frontend... && cd factory/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"]
|
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"]
|
||||||
|
|
@ -10,10 +10,10 @@ COPY quebec /workspace/quebec
|
||||||
COPY rivet-checkout /workspace/rivet-checkout
|
COPY rivet-checkout /workspace/rivet-checkout
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm --filter @openhandoff/shared build
|
RUN pnpm --filter @sandbox-agent/foundry-shared build
|
||||||
RUN pnpm --filter @openhandoff/client build
|
RUN pnpm --filter @sandbox-agent/foundry-client build
|
||||||
RUN pnpm --filter @openhandoff/frontend-errors build
|
RUN pnpm --filter @sandbox-agent/foundry-frontend-errors build
|
||||||
RUN pnpm --filter @openhandoff/frontend build
|
RUN pnpm --filter @sandbox-agent/foundry-frontend build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Factory Cloud
|
# Foundry Cloud
|
||||||
|
|
||||||
## Mock Server
|
## Mock Server
|
||||||
|
|
||||||
|
|
@ -8,5 +8,5 @@ A detached `tmux` session is acceptable for this. Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux new-session -d -s mock-ui-4180 \
|
tmux new-session -d -s mock-ui-4180 \
|
||||||
'cd /Users/nathan/conductor/workspaces/sandbox-agent/provo && OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend exec vite --host localhost --port 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'
|
||||||
```
|
```
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
### claude code/opencode
|
### claude code/opencode
|
||||||
|
|
||||||
1. "handoff this task to do xxxx"
|
1. "task this task to do xxxx"
|
||||||
2. ask clarifying questions
|
2. ask clarifying questions
|
||||||
3. works in background (attach opencode session with `hf attach` and switch to session with `hf switch`)
|
3. works in background (attach opencode session with `hf attach` and switch to session with `hf switch`)
|
||||||
4. automatically submits draft pr (if configured)
|
4. automatically submits draft pr (if configured)
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
- model (for the agent)
|
- model (for the agent)
|
||||||
- todo list & plan management -> with simplenote sync
|
- todo list & plan management -> with simplenote sync
|
||||||
- sqlite (global)
|
- sqlite (global)
|
||||||
- list of all global handoff repos
|
- list of all global task repos
|
||||||
- heartbeat status to tell openclaw what it needs to send you
|
- heartbeat status to tell openclaw what it needs to send you
|
||||||
- sandbox agent sdk support
|
- sandbox agent sdk support
|
||||||
- serve command to run server
|
- serve command to run server
|
||||||
|
|
@ -78,5 +78,5 @@
|
||||||
|
|
||||||
- automatically uses your opencode theme
|
- automatically uses your opencode theme
|
||||||
- auto symlink target/node_modules/etc
|
- auto symlink target/node_modules/etc
|
||||||
- auto-archives handoffs when closed
|
- auto-archives tasks when closed
|
||||||
- shows agent status in the tmux window name
|
- shows agent status in the tmux window name
|
||||||
|
|
@ -10,10 +10,10 @@ WorkspaceActor
|
||||||
├─ ProjectActor(repo)
|
├─ ProjectActor(repo)
|
||||||
│ ├─ ProjectBranchSyncActor
|
│ ├─ ProjectBranchSyncActor
|
||||||
│ ├─ ProjectPrSyncActor
|
│ ├─ ProjectPrSyncActor
|
||||||
│ └─ HandoffActor(handoff)
|
│ └─ TaskActor(task)
|
||||||
│ ├─ HandoffSessionActor(session) × N
|
│ ├─ TaskSessionActor(session) × N
|
||||||
│ │ └─ SessionStatusSyncActor(session) × 0..1
|
│ │ └─ SessionStatusSyncActor(session) × 0..1
|
||||||
│ └─ Handoff-local workbench state
|
│ └─ Task-local workbench state
|
||||||
└─ SandboxInstanceActor(providerId, sandboxId) × N
|
└─ SandboxInstanceActor(providerId, sandboxId) × N
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -22,12 +22,12 @@ WorkspaceActor
|
||||||
- `WorkspaceActor` is the workspace coordinator and lookup/index owner.
|
- `WorkspaceActor` is the workspace coordinator and lookup/index owner.
|
||||||
- `HistoryActor` is workspace-scoped. There is one workspace-level history feed.
|
- `HistoryActor` is workspace-scoped. There is one workspace-level history feed.
|
||||||
- `ProjectActor` is the repo coordinator and owns repo-local caches/indexes.
|
- `ProjectActor` is the repo coordinator and owns repo-local caches/indexes.
|
||||||
- `HandoffActor` is one branch. Treat `1 handoff = 1 branch` once branch assignment is finalized.
|
- `TaskActor` is one branch. Treat `1 task = 1 branch` once branch assignment is finalized.
|
||||||
- `HandoffActor` can have many sessions.
|
- `TaskActor` can have many sessions.
|
||||||
- `HandoffActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time.
|
- `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.
|
- 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.
|
- Branch rename is a real git operation, not just metadata.
|
||||||
- `SandboxInstanceActor` stays separate from `HandoffActor`; handoffs/sessions reference it by identity.
|
- `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.
|
- Sync actors are polling workers only. They feed parent actors and should not become the source of truth.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@openhandoff/backend",
|
"name": "@sandbox-agent/foundry-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm --external bun:sqlite",
|
"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",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "$HOME/.bun/bin/bun x vitest run",
|
"test": "$HOME/.bun/bin/bun x vitest run",
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
"@hono/node-ws": "^1.3.0",
|
"@hono/node-ws": "^1.3.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@openhandoff/shared": "workspace:*",
|
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||||
"@sandbox-agent/persist-rivet": "workspace:*",
|
"@sandbox-agent/persist-rivet": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.5",
|
"drizzle-orm": "^0.44.5",
|
||||||
"hono": "^4.11.9",
|
"hono": "^4.11.9",
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
import type { AppConfig } from "@openhandoff/shared";
|
import type { AppConfig } from "@sandbox-agent/foundry-shared";
|
||||||
import type { BackendDriver } from "../driver.js";
|
import type { BackendDriver } from "../driver.js";
|
||||||
import type { NotificationService } from "../notifications/index.js";
|
import type { NotificationService } from "../notifications/index.js";
|
||||||
import type { ProviderRegistry } from "../providers/index.js";
|
import type { ProviderRegistry } from "../providers/index.js";
|
||||||
|
import type { AppShellServices } from "../services/app-shell-runtime.js";
|
||||||
|
|
||||||
let runtimeConfig: AppConfig | null = null;
|
let runtimeConfig: AppConfig | null = null;
|
||||||
let providerRegistry: ProviderRegistry | null = null;
|
let providerRegistry: ProviderRegistry | null = null;
|
||||||
let notificationService: NotificationService | null = null;
|
let notificationService: NotificationService | null = null;
|
||||||
let runtimeDriver: BackendDriver | null = null;
|
let runtimeDriver: BackendDriver | null = null;
|
||||||
|
let appShellServices: AppShellServices | null = null;
|
||||||
|
|
||||||
export function initActorRuntimeContext(config: AppConfig, providers: ProviderRegistry, notifications?: NotificationService, driver?: BackendDriver): void {
|
export function initActorRuntimeContext(
|
||||||
|
config: AppConfig,
|
||||||
|
providers: ProviderRegistry,
|
||||||
|
notifications?: NotificationService,
|
||||||
|
driver?: BackendDriver,
|
||||||
|
appShell?: AppShellServices,
|
||||||
|
): void {
|
||||||
runtimeConfig = config;
|
runtimeConfig = config;
|
||||||
providerRegistry = providers;
|
providerRegistry = providers;
|
||||||
notificationService = notifications ?? null;
|
notificationService = notifications ?? null;
|
||||||
runtimeDriver = driver ?? null;
|
runtimeDriver = driver ?? null;
|
||||||
|
appShellServices = appShell ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActorRuntimeContext(): {
|
export function getActorRuntimeContext(): {
|
||||||
|
|
@ -20,6 +29,7 @@ export function getActorRuntimeContext(): {
|
||||||
providers: ProviderRegistry;
|
providers: ProviderRegistry;
|
||||||
notifications: NotificationService | null;
|
notifications: NotificationService | null;
|
||||||
driver: BackendDriver;
|
driver: BackendDriver;
|
||||||
|
appShell: AppShellServices;
|
||||||
} {
|
} {
|
||||||
if (!runtimeConfig || !providerRegistry) {
|
if (!runtimeConfig || !providerRegistry) {
|
||||||
throw new Error("Actor runtime context not initialized");
|
throw new Error("Actor runtime context not initialized");
|
||||||
|
|
@ -29,10 +39,15 @@ export function getActorRuntimeContext(): {
|
||||||
throw new Error("Actor runtime context missing driver");
|
throw new Error("Actor runtime context missing driver");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!appShellServices) {
|
||||||
|
throw new Error("Actor runtime context missing app shell services");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: runtimeConfig,
|
config: runtimeConfig,
|
||||||
providers: providerRegistry,
|
providers: providerRegistry,
|
||||||
notifications: notificationService,
|
notifications: notificationService,
|
||||||
driver: runtimeDriver,
|
driver: runtimeDriver,
|
||||||
|
appShell: appShellServices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import type { HandoffStatus, ProviderId } from "@openhandoff/shared";
|
import type { TaskStatus, ProviderId } from "@sandbox-agent/foundry-shared";
|
||||||
|
|
||||||
export interface HandoffCreatedEvent {
|
export interface TaskCreatedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HandoffStatusEvent {
|
export interface TaskStatusEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
status: HandoffStatus;
|
status: TaskStatus;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,28 +26,28 @@ export interface ProjectSnapshotEvent {
|
||||||
export interface AgentStartedEvent {
|
export interface AgentStartedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentIdleEvent {
|
export interface AgentIdleEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentErrorEvent {
|
export interface AgentErrorEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrCreatedEvent {
|
export interface PrCreatedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ export interface PrCreatedEvent {
|
||||||
export interface PrClosedEvent {
|
export interface PrClosedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
merged: boolean;
|
merged: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ export interface PrClosedEvent {
|
||||||
export interface PrReviewEvent {
|
export interface PrReviewEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
reviewer: string;
|
reviewer: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
@ -72,41 +72,41 @@ export interface PrReviewEvent {
|
||||||
export interface CiStatusChangedEvent {
|
export interface CiStatusChangedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HandoffStepName = "auto_commit" | "push" | "pr_submit";
|
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
|
||||||
export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed";
|
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
|
||||||
|
|
||||||
export interface HandoffStepEvent {
|
export interface TaskStepEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
step: HandoffStepName;
|
step: TaskStepName;
|
||||||
status: HandoffStepStatus;
|
status: TaskStepStatus;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BranchSwitchedEvent {
|
export interface BranchSwitchedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionAttachedEvent {
|
export interface SessionAttachedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BranchSyncedEvent {
|
export interface BranchSyncedEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
strategy: string;
|
strategy: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { handoffKey, handoffStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js";
|
import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||||
import type { ProviderId } from "@openhandoff/shared";
|
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||||
|
|
||||||
export function actorClient(c: any) {
|
export function actorClient(c: any) {
|
||||||
return c.client();
|
return c.client();
|
||||||
|
|
@ -25,12 +25,12 @@ export function getProject(c: any, workspaceId: string, repoId: string) {
|
||||||
return actorClient(c).project.get(projectKey(workspaceId, repoId));
|
return actorClient(c).project.get(projectKey(workspaceId, repoId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) {
|
export function getTask(c: any, workspaceId: string, repoId: string, taskId: string) {
|
||||||
return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
|
return actorClient(c).task.get(taskKey(workspaceId, repoId, taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateHandoff(c: any, workspaceId: string, repoId: string, handoffId: string, createWithInput: Record<string, unknown>) {
|
export async function getOrCreateTask(c: any, workspaceId: string, repoId: string, taskId: string, createWithInput: Record<string, unknown>) {
|
||||||
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), {
|
return await actorClient(c).task.getOrCreate(taskKey(workspaceId, repoId, taskId), {
|
||||||
createWithInput,
|
createWithInput,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -80,16 +80,16 @@ export async function getOrCreateSandboxInstance(
|
||||||
return await actorClient(c).sandboxInstance.getOrCreate(sandboxInstanceKey(workspaceId, providerId, sandboxId), { createWithInput });
|
return await actorClient(c).sandboxInstance.getOrCreate(sandboxInstanceKey(workspaceId, providerId, sandboxId), { createWithInput });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateHandoffStatusSync(
|
export async function getOrCreateTaskStatusSync(
|
||||||
c: any,
|
c: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
handoffId: string,
|
taskId: string,
|
||||||
sandboxId: string,
|
sandboxId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
createWithInput: Record<string, unknown>,
|
createWithInput: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
return await actorClient(c).handoffStatusSync.getOrCreate(handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId), {
|
return await actorClient(c).taskStatusSync.getOrCreate(taskStatusSyncKey(workspaceId, repoId, taskId, sandboxId, sessionId), {
|
||||||
createWithInput,
|
createWithInput,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -102,16 +102,16 @@ export function selfProjectBranchSync(c: any) {
|
||||||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selfHandoffStatusSync(c: any) {
|
export function selfTaskStatusSync(c: any) {
|
||||||
return actorClient(c).handoffStatusSync.getForId(c.actorId);
|
return actorClient(c).taskStatusSync.getForId(c.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selfHistory(c: any) {
|
export function selfHistory(c: any) {
|
||||||
return actorClient(c).history.getForId(c.actorId);
|
return actorClient(c).history.getForId(c.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selfHandoff(c: any) {
|
export function selfTask(c: any) {
|
||||||
return actorClient(c).handoff.getForId(c.actorId);
|
return actorClient(c).task.getForId(c.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selfWorkspace(c: any) {
|
export function selfWorkspace(c: any) {
|
||||||
5
foundry/packages/backend/src/actors/history/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/history/db/db.ts
Normal 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 });
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
CREATE TABLE `events` (
|
CREATE TABLE `events` (
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
`handoff_id` text,
|
`task_id` text,
|
||||||
`branch_name` text,
|
`branch_name` text,
|
||||||
`kind` text NOT NULL,
|
`kind` text NOT NULL,
|
||||||
`payload_json` text NOT NULL,
|
`payload_json` text NOT NULL,
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": true
|
"autoincrement": true
|
||||||
},
|
},
|
||||||
"handoff_id": {
|
"task_id": {
|
||||||
"name": "handoff_id",
|
"name": "task_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -18,7 +18,7 @@ export default {
|
||||||
migrations: {
|
migrations: {
|
||||||
m0000: `CREATE TABLE \`events\` (
|
m0000: `CREATE TABLE \`events\` (
|
||||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
\`handoff_id\` text,
|
\`task_id\` text,
|
||||||
\`branch_name\` text,
|
\`branch_name\` text,
|
||||||
\`kind\` text NOT NULL,
|
\`kind\` text NOT NULL,
|
||||||
\`payload_json\` text NOT NULL,
|
\`payload_json\` text NOT NULL,
|
||||||
|
|
@ -2,7 +2,7 @@ import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||||
|
|
||||||
export const events = sqliteTable("events", {
|
export const events = sqliteTable("events", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
handoffId: text("handoff_id"),
|
taskId: text("task_id"),
|
||||||
branchName: text("branch_name"),
|
branchName: text("branch_name"),
|
||||||
kind: text("kind").notNull(),
|
kind: text("kind").notNull(),
|
||||||
payloadJson: text("payload_json").notNull(),
|
payloadJson: text("payload_json").notNull(),
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { actor, queue } from "rivetkit";
|
import { actor, queue } from "rivetkit";
|
||||||
import { Loop, workflow } from "rivetkit/workflow";
|
import { Loop, workflow } from "rivetkit/workflow";
|
||||||
import type { HistoryEvent } from "@openhandoff/shared";
|
import type { HistoryEvent } from "@sandbox-agent/foundry-shared";
|
||||||
import { selfHistory } from "../handles.js";
|
import { selfHistory } from "../handles.js";
|
||||||
import { historyDb } from "./db/db.js";
|
import { historyDb } from "./db/db.js";
|
||||||
import { events } from "./db/schema.js";
|
import { events } from "./db/schema.js";
|
||||||
|
|
@ -14,14 +14,14 @@ export interface HistoryInput {
|
||||||
|
|
||||||
export interface AppendHistoryCommand {
|
export interface AppendHistoryCommand {
|
||||||
kind: string;
|
kind: string;
|
||||||
handoffId?: string;
|
taskId?: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListHistoryParams {
|
export interface ListHistoryParams {
|
||||||
branch?: string;
|
branch?: string;
|
||||||
handoffId?: string;
|
taskId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi
|
||||||
await loopCtx.db
|
await loopCtx.db
|
||||||
.insert(events)
|
.insert(events)
|
||||||
.values({
|
.values({
|
||||||
handoffId: body.handoffId ?? null,
|
taskId: body.taskId ?? null,
|
||||||
branchName: body.branchName ?? null,
|
branchName: body.branchName ?? null,
|
||||||
kind: body.kind,
|
kind: body.kind,
|
||||||
payloadJson: JSON.stringify(body.payload),
|
payloadJson: JSON.stringify(body.payload),
|
||||||
|
|
@ -77,8 +77,8 @@ export const history = actor({
|
||||||
|
|
||||||
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
|
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
|
||||||
const whereParts = [];
|
const whereParts = [];
|
||||||
if (params?.handoffId) {
|
if (params?.taskId) {
|
||||||
whereParts.push(eq(events.handoffId, params.handoffId));
|
whereParts.push(eq(events.taskId, params.taskId));
|
||||||
}
|
}
|
||||||
if (params?.branch) {
|
if (params?.branch) {
|
||||||
whereParts.push(eq(events.branchName, params.branch));
|
whereParts.push(eq(events.branchName, params.branch));
|
||||||
|
|
@ -87,7 +87,7 @@ export const history = actor({
|
||||||
const base = c.db
|
const base = c.db
|
||||||
.select({
|
.select({
|
||||||
id: events.id,
|
id: events.id,
|
||||||
handoffId: events.handoffId,
|
taskId: events.taskId,
|
||||||
branchName: events.branchName,
|
branchName: events.branchName,
|
||||||
kind: events.kind,
|
kind: events.kind,
|
||||||
payloadJson: events.payloadJson,
|
payloadJson: events.payloadJson,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { setup } from "rivetkit";
|
import { setup } from "rivetkit";
|
||||||
import { handoffStatusSync } from "./handoff-status-sync/index.js";
|
import { taskStatusSync } from "./task-status-sync/index.js";
|
||||||
import { handoff } from "./handoff/index.js";
|
import { task } from "./task/index.js";
|
||||||
import { history } from "./history/index.js";
|
import { history } from "./history/index.js";
|
||||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
import { projectPrSync } from "./project-pr-sync/index.js";
|
||||||
|
|
@ -8,7 +8,7 @@ import { project } from "./project/index.js";
|
||||||
import { sandboxInstance } from "./sandbox-instance/index.js";
|
import { sandboxInstance } from "./sandbox-instance/index.js";
|
||||||
import { workspace } from "./workspace/index.js";
|
import { workspace } from "./workspace/index.js";
|
||||||
|
|
||||||
function resolveManagerPort(): number {
|
export function resolveManagerPort(): number {
|
||||||
const raw = process.env.HF_RIVET_MANAGER_PORT ?? process.env.RIVETKIT_MANAGER_PORT;
|
const raw = process.env.HF_RIVET_MANAGER_PORT ?? process.env.RIVETKIT_MANAGER_PORT;
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return 7750;
|
return 7750;
|
||||||
|
|
@ -30,12 +30,12 @@ export const registry = setup({
|
||||||
use: {
|
use: {
|
||||||
workspace,
|
workspace,
|
||||||
project,
|
project,
|
||||||
handoff,
|
task,
|
||||||
sandboxInstance,
|
sandboxInstance,
|
||||||
history,
|
history,
|
||||||
projectPrSync,
|
projectPrSync,
|
||||||
projectBranchSync,
|
projectBranchSync,
|
||||||
handoffStatusSync,
|
taskStatusSync,
|
||||||
},
|
},
|
||||||
managerPort: resolveManagerPort(),
|
managerPort: resolveManagerPort(),
|
||||||
managerHost: resolveManagerHost(),
|
managerHost: resolveManagerHost(),
|
||||||
|
|
@ -43,8 +43,8 @@ export const registry = setup({
|
||||||
|
|
||||||
export * from "./context.js";
|
export * from "./context.js";
|
||||||
export * from "./events.js";
|
export * from "./events.js";
|
||||||
export * from "./handoff-status-sync/index.js";
|
export * from "./task-status-sync/index.js";
|
||||||
export * from "./handoff/index.js";
|
export * from "./task/index.js";
|
||||||
export * from "./history/index.js";
|
export * from "./history/index.js";
|
||||||
export * from "./keys.js";
|
export * from "./keys.js";
|
||||||
export * from "./project-branch-sync/index.js";
|
export * from "./project-branch-sync/index.js";
|
||||||
|
|
@ -8,8 +8,8 @@ export function projectKey(workspaceId: string, repoId: string): ActorKey {
|
||||||
return ["ws", workspaceId, "project", repoId];
|
return ["ws", workspaceId, "project", repoId];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
|
export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey {
|
||||||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
|
return ["ws", workspaceId, "project", repoId, "task", taskId];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
|
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
|
||||||
|
|
@ -28,7 +28,7 @@ export function projectBranchSyncKey(workspaceId: string, repoId: string): Actor
|
||||||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: string, sessionId: string): ActorKey {
|
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 handoff.
|
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
|
||||||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
|
return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
|
||||||
}
|
}
|
||||||
|
|
@ -23,5 +23,5 @@ export function logActorWarning(scope: string, message: string, context?: Record
|
||||||
...(context ?? {}),
|
...(context ?? {}),
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn("[openhandoff][actor:warn]", payload);
|
console.warn("[foundry][actor:warn]", payload);
|
||||||
}
|
}
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
||||||
import { Loop } from "rivetkit/workflow";
|
import { Loop } from "rivetkit/workflow";
|
||||||
import type { AgentType, HandoffRecord, HandoffSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@openhandoff/shared";
|
import type { AgentType, TaskRecord, TaskSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@sandbox-agent/foundry-shared";
|
||||||
import { getActorRuntimeContext } from "../context.js";
|
import { getActorRuntimeContext } from "../context.js";
|
||||||
import { getHandoff, getOrCreateHandoff, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
||||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||||
import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.js";
|
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
||||||
import { expectQueueResponse } from "../../services/queue.js";
|
import { expectQueueResponse } from "../../services/queue.js";
|
||||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||||
import { branches, handoffIndex, prCache, repoMeta } from "./db/schema.js";
|
import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js";
|
||||||
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
||||||
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
||||||
import { sortBranchesForOverview } from "./stack-model.js";
|
import { sortBranchesForOverview } from "./stack-model.js";
|
||||||
|
|
@ -22,7 +22,7 @@ interface EnsureProjectResult {
|
||||||
localPath: string;
|
localPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateHandoffCommand {
|
interface CreateTaskCommand {
|
||||||
task: string;
|
task: string;
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
agentType: AgentType | null;
|
agentType: AgentType | null;
|
||||||
|
|
@ -32,22 +32,22 @@ interface CreateHandoffCommand {
|
||||||
onBranch: string | null;
|
onBranch: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HydrateHandoffIndexCommand {}
|
interface HydrateTaskIndexCommand {}
|
||||||
|
|
||||||
interface ListReservedBranchesCommand {}
|
interface ListReservedBranchesCommand {}
|
||||||
|
|
||||||
interface RegisterHandoffBranchCommand {
|
interface RegisterTaskBranchCommand {
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
requireExistingRemote?: boolean;
|
requireExistingRemote?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListHandoffSummariesCommand {
|
interface ListTaskSummariesCommand {
|
||||||
includeArchived?: boolean;
|
includeArchived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetHandoffEnrichedCommand {
|
interface GetTaskEnrichedCommand {
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetPullRequestForBranchCommand {
|
interface GetPullRequestForBranchCommand {
|
||||||
|
|
@ -93,9 +93,9 @@ interface RunRepoStackActionCommand {
|
||||||
|
|
||||||
const PROJECT_QUEUE_NAMES = [
|
const PROJECT_QUEUE_NAMES = [
|
||||||
"project.command.ensure",
|
"project.command.ensure",
|
||||||
"project.command.hydrateHandoffIndex",
|
"project.command.hydrateTaskIndex",
|
||||||
"project.command.createHandoff",
|
"project.command.createTask",
|
||||||
"project.command.registerHandoffBranch",
|
"project.command.registerTaskBranch",
|
||||||
"project.command.runRepoStackAction",
|
"project.command.runRepoStackAction",
|
||||||
"project.command.applyPrSyncResult",
|
"project.command.applyPrSyncResult",
|
||||||
"project.command.applyBranchSyncResult",
|
"project.command.applyBranchSyncResult",
|
||||||
|
|
@ -111,7 +111,7 @@ export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueNa
|
||||||
|
|
||||||
async function ensureLocalClone(c: any, remoteUrl: string): Promise<string> {
|
async function ensureLocalClone(c: any, remoteUrl: string): Promise<string> {
|
||||||
const { config, driver } = getActorRuntimeContext();
|
const { config, driver } = getActorRuntimeContext();
|
||||||
const localPath = openhandoffRepoClonePath(config, c.state.workspaceId, c.state.repoId);
|
const localPath = foundryRepoClonePath(config, c.state.workspaceId, c.state.repoId);
|
||||||
await driver.git.ensureCloned(remoteUrl, localPath);
|
await driver.git.ensureCloned(remoteUrl, localPath);
|
||||||
c.state.localPath = localPath;
|
c.state.localPath = localPath;
|
||||||
return localPath;
|
return localPath;
|
||||||
|
|
@ -131,59 +131,59 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
|
||||||
c.state.syncActorsStarted = true;
|
c.state.syncActorsStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteStaleHandoffIndexRow(c: any, handoffId: string): Promise<void> {
|
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run();
|
await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run();
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cleanup only; preserve the original caller flow.
|
// Best-effort cleanup only; preserve the original caller flow.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStaleHandoffReferenceError(error: unknown): boolean {
|
function isStaleTaskReferenceError(error: unknown): boolean {
|
||||||
const message = resolveErrorMessage(error);
|
const message = resolveErrorMessage(error);
|
||||||
return isActorNotFoundError(error) || message.startsWith("Handoff not found:");
|
return isActorNotFoundError(error) || message.startsWith("Task not found:");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureHandoffIndexHydrated(c: any): Promise<void> {
|
async function ensureTaskIndexHydrated(c: any): Promise<void> {
|
||||||
if (c.state.handoffIndexHydrated) {
|
if (c.state.taskIndexHydrated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).limit(1).get();
|
const existing = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).limit(1).get();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
c.state.handoffIndexHydrated = true;
|
c.state.taskIndexHydrated = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration path for old project actors that only tracked handoffs in history.
|
// Migration path for old project actors that only tracked tasks in history.
|
||||||
try {
|
try {
|
||||||
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
|
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
|
||||||
const rows = await history.list({ limit: 5_000 });
|
const rows = await history.list({ limit: 5_000 });
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let skippedMissingHandoffActors = 0;
|
let skippedMissingTaskActors = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.handoffId || seen.has(row.handoffId)) {
|
if (!row.taskId || seen.has(row.taskId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(row.handoffId);
|
seen.add(row.taskId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId);
|
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
||||||
await h.get();
|
await h.get();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStaleHandoffReferenceError(error)) {
|
if (isStaleTaskReferenceError(error)) {
|
||||||
skippedMissingHandoffActors += 1;
|
skippedMissingTaskActors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await c.db
|
await c.db
|
||||||
.insert(handoffIndex)
|
.insert(taskIndex)
|
||||||
.values({
|
.values({
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
branchName: row.branchName,
|
branchName: row.branchName,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.createdAt,
|
updatedAt: row.createdAt,
|
||||||
|
|
@ -192,22 +192,22 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skippedMissingHandoffActors > 0) {
|
if (skippedMissingTaskActors > 0) {
|
||||||
logActorWarning("project", "skipped missing handoffs while hydrating index", {
|
logActorWarning("project", "skipped missing tasks while hydrating index", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
skippedMissingHandoffActors,
|
skippedMissingTaskActors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logActorWarning("project", "handoff index hydration from history failed", {
|
logActorWarning("project", "task index hydration from history failed", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
error: resolveErrorMessage(error),
|
error: resolveErrorMessage(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
c.state.handoffIndexHydrated = true;
|
c.state.taskIndexHydrated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureProjectReady(c: any): Promise<string> {
|
async function ensureProjectReady(c: any): Promise<string> {
|
||||||
|
|
@ -241,11 +241,11 @@ async function ensureProjectReadyForRead(c: any): Promise<string> {
|
||||||
return c.state.localPath;
|
return c.state.localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureHandoffIndexHydratedForRead(c: any): Promise<void> {
|
async function ensureTaskIndexHydratedForRead(c: any): Promise<void> {
|
||||||
if (c.state.handoffIndexHydrated) {
|
if (c.state.taskIndexHydrated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await projectActions.hydrateHandoffIndex(c, {});
|
await projectActions.hydrateTaskIndex(c, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
||||||
|
|
@ -256,7 +256,7 @@ async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
||||||
await branchSync.force();
|
await branchSync.force();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise<HandoffRecord> {
|
async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord> {
|
||||||
const branchName = record.branchName;
|
const branchName = record.branchName;
|
||||||
const br =
|
const br =
|
||||||
branchName != null
|
branchName != null
|
||||||
|
|
@ -325,16 +325,16 @@ async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise
|
||||||
return { localPath };
|
return { localPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateHandoffIndexMutation(c: any, _cmd?: HydrateHandoffIndexCommand): Promise<void> {
|
async function hydrateTaskIndexMutation(c: any, _cmd?: HydrateTaskIndexCommand): Promise<void> {
|
||||||
await ensureHandoffIndexHydrated(c);
|
await ensureTaskIndexHydrated(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise<HandoffRecord> {
|
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||||
const localPath = await ensureProjectReady(c);
|
const localPath = await ensureProjectReady(c);
|
||||||
const onBranch = cmd.onBranch?.trim() || null;
|
const onBranch = cmd.onBranch?.trim() || null;
|
||||||
const initialBranchName = onBranch;
|
const initialBranchName = onBranch;
|
||||||
const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null;
|
const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null;
|
||||||
const handoffId = randomUUID();
|
const taskId = randomUUID();
|
||||||
|
|
||||||
if (onBranch) {
|
if (onBranch) {
|
||||||
await forceProjectSync(c, localPath);
|
await forceProjectSync(c, localPath);
|
||||||
|
|
@ -344,19 +344,19 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
||||||
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
|
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await registerHandoffBranchMutation(c, {
|
await registerTaskBranchMutation(c, {
|
||||||
handoffId,
|
taskId,
|
||||||
branchName: onBranch,
|
branchName: onBranch,
|
||||||
requireExistingRemote: true,
|
requireExistingRemote: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let handoff: Awaited<ReturnType<typeof getOrCreateHandoff>>;
|
let task: Awaited<ReturnType<typeof getOrCreateTask>>;
|
||||||
try {
|
try {
|
||||||
handoff = await getOrCreateHandoff(c, c.state.workspaceId, c.state.repoId, handoffId, {
|
task = await getOrCreateTask(c, c.state.workspaceId, c.state.repoId, taskId, {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId,
|
taskId,
|
||||||
repoRemote: c.state.remoteUrl,
|
repoRemote: c.state.remoteUrl,
|
||||||
repoLocalPath: localPath,
|
repoLocalPath: localPath,
|
||||||
branchName: initialBranchName,
|
branchName: initialBranchName,
|
||||||
|
|
@ -371,8 +371,8 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onBranch) {
|
if (onBranch) {
|
||||||
await c.db
|
await c.db
|
||||||
.delete(handoffIndex)
|
.delete(taskIndex)
|
||||||
.where(eq(handoffIndex.handoffId, handoffId))
|
.where(eq(taskIndex.taskId, taskId))
|
||||||
.run()
|
.run()
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -382,9 +382,9 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
||||||
if (!onBranch) {
|
if (!onBranch) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await c.db
|
await c.db
|
||||||
.insert(handoffIndex)
|
.insert(taskIndex)
|
||||||
.values({
|
.values({
|
||||||
handoffId,
|
taskId,
|
||||||
branchName: initialBranchName,
|
branchName: initialBranchName,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -393,12 +393,12 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await handoff.initialize({ providerId: cmd.providerId });
|
const created = await task.initialize({ providerId: cmd.providerId });
|
||||||
|
|
||||||
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
|
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
|
||||||
await history.append({
|
await history.append({
|
||||||
kind: "handoff.created",
|
kind: "task.created",
|
||||||
handoffId,
|
taskId,
|
||||||
payload: {
|
payload: {
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
providerId: cmd.providerId,
|
providerId: cmd.providerId,
|
||||||
|
|
@ -408,7 +408,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||||
const localPath = await ensureProjectReady(c);
|
const localPath = await ensureProjectReady(c);
|
||||||
|
|
||||||
const branchName = cmd.branchName.trim();
|
const branchName = cmd.branchName.trim();
|
||||||
|
|
@ -417,27 +417,27 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC
|
||||||
throw new Error("branchName is required");
|
throw new Error("branchName is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureHandoffIndexHydrated(c);
|
await ensureTaskIndexHydrated(c);
|
||||||
|
|
||||||
const existingOwner = await c.db
|
const existingOwner = await c.db
|
||||||
.select({ handoffId: handoffIndex.handoffId })
|
.select({ taskId: taskIndex.taskId })
|
||||||
.from(handoffIndex)
|
.from(taskIndex)
|
||||||
.where(and(eq(handoffIndex.branchName, branchName), ne(handoffIndex.handoffId, cmd.handoffId)))
|
.where(and(eq(taskIndex.branchName, branchName), ne(taskIndex.taskId, cmd.taskId)))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (existingOwner) {
|
if (existingOwner) {
|
||||||
let ownerMissing = false;
|
let ownerMissing = false;
|
||||||
try {
|
try {
|
||||||
const h = getHandoff(c, c.state.workspaceId, c.state.repoId, existingOwner.handoffId);
|
const h = getTask(c, c.state.workspaceId, c.state.repoId, existingOwner.taskId);
|
||||||
await h.get();
|
await h.get();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStaleHandoffReferenceError(error)) {
|
if (isStaleTaskReferenceError(error)) {
|
||||||
ownerMissing = true;
|
ownerMissing = true;
|
||||||
await deleteStaleHandoffIndexRow(c, existingOwner.handoffId);
|
await deleteStaleTaskIndexRow(c, existingOwner.taskId);
|
||||||
logActorWarning("project", "pruned stale handoff index row during branch registration", {
|
logActorWarning("project", "pruned stale task index row during branch registration", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: existingOwner.handoffId,
|
taskId: existingOwner.taskId,
|
||||||
branchName,
|
branchName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -445,7 +445,7 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!ownerMissing) {
|
if (!ownerMissing) {
|
||||||
throw new Error(`branch is already assigned to a different handoff: ${branchName}`);
|
throw new Error(`branch is already assigned to a different task: ${branchName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,15 +525,15 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
await c.db
|
await c.db
|
||||||
.insert(handoffIndex)
|
.insert(taskIndex)
|
||||||
.values({
|
.values({
|
||||||
handoffId: cmd.handoffId,
|
taskId: cmd.taskId,
|
||||||
branchName,
|
branchName,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: handoffIndex.handoffId,
|
target: taskIndex.taskId,
|
||||||
set: {
|
set: {
|
||||||
branchName,
|
branchName,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -546,7 +546,7 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC
|
||||||
|
|
||||||
async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
||||||
const localPath = await ensureProjectReady(c);
|
const localPath = await ensureProjectReady(c);
|
||||||
await ensureHandoffIndexHydrated(c);
|
await ensureTaskIndexHydrated(c);
|
||||||
|
|
||||||
const { driver } = getActorRuntimeContext();
|
const { driver } = getActorRuntimeContext();
|
||||||
const at = Date.now();
|
const at = Date.now();
|
||||||
|
|
@ -682,30 +682,30 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.branchName, item.headRefName)).get();
|
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.branchName, item.headRefName)).get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId);
|
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
||||||
await h.archive({ reason: `PR ${item.state.toLowerCase()}` });
|
await h.archive({ reason: `PR ${item.state.toLowerCase()}` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStaleHandoffReferenceError(error)) {
|
if (isStaleTaskReferenceError(error)) {
|
||||||
await deleteStaleHandoffIndexRow(c, row.handoffId);
|
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||||
logActorWarning("project", "pruned stale handoff index row during PR close archive", {
|
logActorWarning("project", "pruned stale task index row during PR close archive", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
branchName: item.headRefName,
|
branchName: item.headRefName,
|
||||||
prState: item.state,
|
prState: item.state,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
logActorWarning("project", "failed to auto-archive handoff after PR close", {
|
logActorWarning("project", "failed to auto-archive task after PR close", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
branchName: item.headRefName,
|
branchName: item.headRefName,
|
||||||
prState: item.state,
|
prState: item.state,
|
||||||
error: resolveErrorMessage(error),
|
error: resolveErrorMessage(error),
|
||||||
|
|
@ -787,27 +787,27 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.hydrateHandoffIndex") {
|
if (msg.name === "project.command.hydrateTaskIndex") {
|
||||||
await loopCtx.step("project-hydrate-handoff-index", async () => hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand));
|
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
||||||
await msg.complete({ ok: true });
|
await msg.complete({ ok: true });
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.createHandoff") {
|
if (msg.name === "project.command.createTask") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "project-create-handoff",
|
name: "project-create-task",
|
||||||
timeout: 12 * 60_000,
|
timeout: 12 * 60_000,
|
||||||
run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand),
|
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.registerHandoffBranch") {
|
if (msg.name === "project.command.registerTaskBranch") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "project-register-handoff-branch",
|
name: "project-register-task-branch",
|
||||||
timeout: 5 * 60_000,
|
timeout: 5 * 60_000,
|
||||||
run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand),
|
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
|
|
@ -857,10 +857,10 @@ export const projectActions = {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createHandoff(c: any, cmd: CreateHandoffCommand): Promise<HandoffRecord> {
|
async createTask(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||||
const self = selfProject(c);
|
const self = selfProject(c);
|
||||||
return expectQueueResponse<HandoffRecord>(
|
return expectQueueResponse<TaskRecord>(
|
||||||
await self.send(projectWorkflowQueueName("project.command.createHandoff"), cmd, {
|
await self.send(projectWorkflowQueueName("project.command.createTask"), cmd, {
|
||||||
wait: true,
|
wait: true,
|
||||||
timeout: 12 * 60_000,
|
timeout: 12 * 60_000,
|
||||||
}),
|
}),
|
||||||
|
|
@ -868,42 +868,42 @@ export const projectActions = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise<string[]> {
|
async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise<string[]> {
|
||||||
await ensureHandoffIndexHydratedForRead(c);
|
await ensureTaskIndexHydratedForRead(c);
|
||||||
|
|
||||||
const rows = await c.db.select({ branchName: handoffIndex.branchName }).from(handoffIndex).where(isNotNull(handoffIndex.branchName)).all();
|
const rows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
||||||
|
|
||||||
return rows.map((row) => row.branchName).filter((name): name is string => typeof name === "string" && name.trim().length > 0);
|
return rows.map((row) => row.branchName).filter((name): name is string => typeof name === "string" && name.trim().length > 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
async registerHandoffBranch(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||||
const self = selfProject(c);
|
const self = selfProject(c);
|
||||||
return expectQueueResponse<{ branchName: string; headSha: string }>(
|
return expectQueueResponse<{ branchName: string; headSha: string }>(
|
||||||
await self.send(projectWorkflowQueueName("project.command.registerHandoffBranch"), cmd, {
|
await self.send(projectWorkflowQueueName("project.command.registerTaskBranch"), cmd, {
|
||||||
wait: true,
|
wait: true,
|
||||||
timeout: 5 * 60_000,
|
timeout: 5 * 60_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async hydrateHandoffIndex(c: any, cmd?: HydrateHandoffIndexCommand): Promise<void> {
|
async hydrateTaskIndex(c: any, cmd?: HydrateTaskIndexCommand): Promise<void> {
|
||||||
const self = selfProject(c);
|
const self = selfProject(c);
|
||||||
await self.send(projectWorkflowQueueName("project.command.hydrateHandoffIndex"), cmd ?? {}, {
|
await self.send(projectWorkflowQueueName("project.command.hydrateTaskIndex"), cmd ?? {}, {
|
||||||
wait: true,
|
wait: true,
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async listHandoffSummaries(c: any, cmd?: ListHandoffSummariesCommand): Promise<HandoffSummary[]> {
|
async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise<TaskSummary[]> {
|
||||||
const body = cmd ?? {};
|
const body = cmd ?? {};
|
||||||
const records: HandoffSummary[] = [];
|
const records: TaskSummary[] = [];
|
||||||
|
|
||||||
await ensureHandoffIndexHydratedForRead(c);
|
await ensureTaskIndexHydratedForRead(c);
|
||||||
|
|
||||||
const handoffRows = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).orderBy(desc(handoffIndex.updatedAt)).all();
|
const taskRows = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).orderBy(desc(taskIndex.updatedAt)).all();
|
||||||
|
|
||||||
for (const row of handoffRows) {
|
for (const row of taskRows) {
|
||||||
try {
|
try {
|
||||||
const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId);
|
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
||||||
const record = await h.get();
|
const record = await h.get();
|
||||||
|
|
||||||
if (!body.includeArchived && record.status === "archived") {
|
if (!body.includeArchived && record.status === "archived") {
|
||||||
|
|
@ -913,26 +913,26 @@ export const projectActions = {
|
||||||
records.push({
|
records.push({
|
||||||
workspaceId: record.workspaceId,
|
workspaceId: record.workspaceId,
|
||||||
repoId: record.repoId,
|
repoId: record.repoId,
|
||||||
handoffId: record.handoffId,
|
taskId: record.taskId,
|
||||||
branchName: record.branchName,
|
branchName: record.branchName,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
updatedAt: record.updatedAt,
|
updatedAt: record.updatedAt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStaleHandoffReferenceError(error)) {
|
if (isStaleTaskReferenceError(error)) {
|
||||||
await deleteStaleHandoffIndexRow(c, row.handoffId);
|
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||||
logActorWarning("project", "pruned stale handoff index row during summary listing", {
|
logActorWarning("project", "pruned stale task index row during summary listing", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
logActorWarning("project", "failed loading handoff summary row", {
|
logActorWarning("project", "failed loading task summary row", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
error: resolveErrorMessage(error),
|
error: resolveErrorMessage(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -942,22 +942,22 @@ export const projectActions = {
|
||||||
return records;
|
return records;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise<HandoffRecord> {
|
async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise<TaskRecord> {
|
||||||
await ensureHandoffIndexHydratedForRead(c);
|
await ensureTaskIndexHydratedForRead(c);
|
||||||
|
|
||||||
const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.handoffId, cmd.handoffId)).get();
|
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`);
|
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const h = getHandoff(c, c.state.workspaceId, c.state.repoId, cmd.handoffId);
|
const h = getTask(c, c.state.workspaceId, c.state.repoId, cmd.taskId);
|
||||||
const record = await h.get();
|
const record = await h.get();
|
||||||
return await enrichHandoffRecord(c, record);
|
return await enrichTaskRecord(c, record);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStaleHandoffReferenceError(error)) {
|
if (isStaleTaskReferenceError(error)) {
|
||||||
await deleteStaleHandoffIndexRow(c, cmd.handoffId);
|
await deleteStaleTaskIndexRow(c, cmd.taskId);
|
||||||
throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`);
|
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -965,7 +965,7 @@ export const projectActions = {
|
||||||
|
|
||||||
async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise<RepoOverview> {
|
async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise<RepoOverview> {
|
||||||
const localPath = await ensureProjectReadyForRead(c);
|
const localPath = await ensureProjectReadyForRead(c);
|
||||||
await ensureHandoffIndexHydratedForRead(c);
|
await ensureTaskIndexHydratedForRead(c);
|
||||||
await forceProjectSync(c, localPath);
|
await forceProjectSync(c, localPath);
|
||||||
|
|
||||||
const { driver } = getActorRuntimeContext();
|
const { driver } = getActorRuntimeContext();
|
||||||
|
|
@ -989,45 +989,45 @@ export const projectActions = {
|
||||||
.from(branches)
|
.from(branches)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const handoffRows = await c.db
|
const taskRows = await c.db
|
||||||
.select({
|
.select({
|
||||||
handoffId: handoffIndex.handoffId,
|
taskId: taskIndex.taskId,
|
||||||
branchName: handoffIndex.branchName,
|
branchName: taskIndex.branchName,
|
||||||
updatedAt: handoffIndex.updatedAt,
|
updatedAt: taskIndex.updatedAt,
|
||||||
})
|
})
|
||||||
.from(handoffIndex)
|
.from(taskIndex)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const handoffMetaByBranch = new Map<string, { handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number }>();
|
const taskMetaByBranch = new Map<string, { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number }>();
|
||||||
|
|
||||||
for (const row of handoffRows) {
|
for (const row of taskRows) {
|
||||||
if (!row.branchName) {
|
if (!row.branchName) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId);
|
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
||||||
const record = await h.get();
|
const record = await h.get();
|
||||||
handoffMetaByBranch.set(row.branchName, {
|
taskMetaByBranch.set(row.branchName, {
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
title: record.title ?? null,
|
title: record.title ?? null,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
updatedAt: record.updatedAt,
|
updatedAt: record.updatedAt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStaleHandoffReferenceError(error)) {
|
if (isStaleTaskReferenceError(error)) {
|
||||||
await deleteStaleHandoffIndexRow(c, row.handoffId);
|
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||||
logActorWarning("project", "pruned stale handoff index row during repo overview", {
|
logActorWarning("project", "pruned stale task index row during repo overview", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
branchName: row.branchName,
|
branchName: row.branchName,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
logActorWarning("project", "failed loading handoff while building repo overview", {
|
logActorWarning("project", "failed loading task while building repo overview", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
handoffId: row.handoffId,
|
taskId: row.taskId,
|
||||||
branchName: row.branchName,
|
branchName: row.branchName,
|
||||||
error: resolveErrorMessage(error),
|
error: resolveErrorMessage(error),
|
||||||
});
|
});
|
||||||
|
|
@ -1060,7 +1060,7 @@ export const projectActions = {
|
||||||
|
|
||||||
const branchRows = combinedRows.map((ordering) => {
|
const branchRows = combinedRows.map((ordering) => {
|
||||||
const row = detailByBranch.get(ordering.branchName)!;
|
const row = detailByBranch.get(ordering.branchName)!;
|
||||||
const handoffMeta = handoffMetaByBranch.get(row.branchName);
|
const taskMeta = taskMetaByBranch.get(row.branchName);
|
||||||
const pr = prByBranch.get(row.branchName);
|
const pr = prByBranch.get(row.branchName);
|
||||||
return {
|
return {
|
||||||
branchName: row.branchName,
|
branchName: row.branchName,
|
||||||
|
|
@ -1070,9 +1070,9 @@ export const projectActions = {
|
||||||
diffStat: row.diffStat ?? null,
|
diffStat: row.diffStat ?? null,
|
||||||
hasUnpushed: Boolean(row.hasUnpushed),
|
hasUnpushed: Boolean(row.hasUnpushed),
|
||||||
conflictsWithMain: Boolean(row.conflictsWithMain),
|
conflictsWithMain: Boolean(row.conflictsWithMain),
|
||||||
handoffId: handoffMeta?.handoffId ?? null,
|
taskId: taskMeta?.taskId ?? null,
|
||||||
handoffTitle: handoffMeta?.title ?? null,
|
taskTitle: taskMeta?.title ?? null,
|
||||||
handoffStatus: handoffMeta?.status ?? null,
|
taskStatus: taskMeta?.status ?? null,
|
||||||
prNumber: pr?.prNumber ?? null,
|
prNumber: pr?.prNumber ?? null,
|
||||||
prState: pr?.prState ?? null,
|
prState: pr?.prState ?? null,
|
||||||
prUrl: pr?.prUrl ?? null,
|
prUrl: pr?.prUrl ?? null,
|
||||||
|
|
@ -1081,7 +1081,7 @@ export const projectActions = {
|
||||||
reviewer: pr?.reviewer ?? null,
|
reviewer: pr?.reviewer ?? null,
|
||||||
firstSeenAt: row.firstSeenAt ?? null,
|
firstSeenAt: row.firstSeenAt ?? null,
|
||||||
lastSeenAt: row.lastSeenAt ?? null,
|
lastSeenAt: row.lastSeenAt ?? null,
|
||||||
updatedAt: Math.max(row.updatedAt, handoffMeta?.updatedAt ?? 0),
|
updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
5
foundry/packages/backend/src/actors/project/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/project/db/db.ts
Normal 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 });
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
CREATE TABLE `handoff_index` (
|
CREATE TABLE `task_index` (
|
||||||
`handoff_id` text PRIMARY KEY NOT NULL,
|
`task_id` text PRIMARY KEY NOT NULL,
|
||||||
`branch_name` text,
|
`branch_name` text,
|
||||||
`created_at` integer NOT NULL,
|
`created_at` integer NOT NULL,
|
||||||
`updated_at` integer NOT NULL
|
`updated_at` integer NOT NULL
|
||||||
|
|
@ -77,11 +77,11 @@
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"handoff_index": {
|
"task_index": {
|
||||||
"name": "handoff_index",
|
"name": "task_index",
|
||||||
"columns": {
|
"columns": {
|
||||||
"handoff_id": {
|
"task_id": {
|
||||||
"name": "handoff_id",
|
"name": "task_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": true,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|
@ -69,8 +69,8 @@ CREATE TABLE \`pr_cache\` (
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
|
ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
|
||||||
m0002: `CREATE TABLE \`handoff_index\` (
|
m0002: `CREATE TABLE \`task_index\` (
|
||||||
\`handoff_id\` text PRIMARY KEY NOT NULL,
|
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||||
\`branch_name\` text,
|
\`branch_name\` text,
|
||||||
\`created_at\` integer NOT NULL,
|
\`created_at\` integer NOT NULL,
|
||||||
\`updated_at\` integer NOT NULL
|
\`updated_at\` integer NOT NULL
|
||||||
|
|
@ -36,8 +36,8 @@ export const prCache = sqliteTable("pr_cache", {
|
||||||
updatedAt: integer("updated_at").notNull(),
|
updatedAt: integer("updated_at").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const handoffIndex = sqliteTable("handoff_index", {
|
export const taskIndex = sqliteTable("task_index", {
|
||||||
handoffId: text("handoff_id").notNull().primaryKey(),
|
taskId: text("task_id").notNull().primaryKey(),
|
||||||
branchName: text("branch_name"),
|
branchName: text("branch_name"),
|
||||||
createdAt: integer("created_at").notNull(),
|
createdAt: integer("created_at").notNull(),
|
||||||
updatedAt: integer("updated_at").notNull(),
|
updatedAt: integer("updated_at").notNull(),
|
||||||
|
|
@ -21,7 +21,7 @@ export const project = actor({
|
||||||
remoteUrl: input.remoteUrl,
|
remoteUrl: input.remoteUrl,
|
||||||
localPath: null as string | null,
|
localPath: null as string | null,
|
||||||
syncActorsStarted: false,
|
syncActorsStarted: false,
|
||||||
handoffIndexHydrated: false,
|
taskIndexHydrated: false,
|
||||||
}),
|
}),
|
||||||
actions: projectActions,
|
actions: projectActions,
|
||||||
run: workflow(runProjectWorkflow),
|
run: workflow(runProjectWorkflow),
|
||||||
|
|
@ -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 });
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||||
|
|
||||||
// SQLite is per sandbox-instance actor instance.
|
// SQLite is per sandbox-instance actor instance.
|
||||||
export const sandboxInstance = sqliteTable("sandbox_instance", {
|
export const sandboxInstance = sqliteTable("sandbox_instance", {
|
||||||
|
|
@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { actor, queue } from "rivetkit";
|
import { actor, queue } from "rivetkit";
|
||||||
import { Loop, workflow } from "rivetkit/workflow";
|
import { Loop, workflow } from "rivetkit/workflow";
|
||||||
import type { ProviderId } from "@openhandoff/shared";
|
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||||
import type {
|
import type {
|
||||||
ProcessCreateRequest,
|
ProcessCreateRequest,
|
||||||
ProcessInfo,
|
ProcessInfo,
|
||||||
|
|
@ -482,28 +482,19 @@ export const sandboxInstance = actor({
|
||||||
return await client.listProcesses();
|
return await client.listProcesses();
|
||||||
},
|
},
|
||||||
|
|
||||||
async getProcessLogs(
|
async getProcessLogs(c: any, request: { processId: string; query?: ProcessLogFollowQuery }): Promise<ProcessLogsResponse> {
|
||||||
c: any,
|
|
||||||
request: { processId: string; query?: ProcessLogFollowQuery }
|
|
||||||
): Promise<ProcessLogsResponse> {
|
|
||||||
const client = await getSandboxAgentClient(c);
|
const client = await getSandboxAgentClient(c);
|
||||||
return await client.getProcessLogs(request.processId, request.query);
|
return await client.getProcessLogs(request.processId, request.query);
|
||||||
},
|
},
|
||||||
|
|
||||||
async stopProcess(
|
async stopProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
|
||||||
c: any,
|
|
||||||
request: { processId: string; query?: ProcessSignalQuery }
|
|
||||||
): Promise<ProcessInfo> {
|
|
||||||
const client = await getSandboxAgentClient(c);
|
const client = await getSandboxAgentClient(c);
|
||||||
const stopped = await client.stopProcess(request.processId, request.query);
|
const stopped = await client.stopProcess(request.processId, request.query);
|
||||||
broadcastProcessesUpdated(c);
|
broadcastProcessesUpdated(c);
|
||||||
return stopped;
|
return stopped;
|
||||||
},
|
},
|
||||||
|
|
||||||
async killProcess(
|
async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
|
||||||
c: any,
|
|
||||||
request: { processId: string; query?: ProcessSignalQuery }
|
|
||||||
): Promise<ProcessInfo> {
|
|
||||||
const client = await getSandboxAgentClient(c);
|
const client = await getSandboxAgentClient(c);
|
||||||
const killed = await client.killProcess(request.processId, request.query);
|
const killed = await client.killProcess(request.processId, request.query);
|
||||||
broadcastProcessesUpdated(c);
|
broadcastProcessesUpdated(c);
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { actor, queue } from "rivetkit";
|
import { actor, queue } from "rivetkit";
|
||||||
import { workflow } from "rivetkit/workflow";
|
import { workflow } from "rivetkit/workflow";
|
||||||
import type { ProviderId } from "@openhandoff/shared";
|
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||||
import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js";
|
import { getTask, getSandboxInstance, selfTaskStatusSync } from "../handles.js";
|
||||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||||
|
|
||||||
export interface HandoffStatusSyncInput {
|
export interface TaskStatusSyncInput {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
sandboxId: string;
|
sandboxId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -19,27 +19,27 @@ interface SetIntervalCommand {
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HandoffStatusSyncState extends PollingControlState {
|
interface TaskStatusSyncState extends PollingControlState {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
handoffId: string;
|
taskId: string;
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
sandboxId: string;
|
sandboxId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTROL = {
|
const CONTROL = {
|
||||||
start: "handoff.status_sync.control.start",
|
start: "task.status_sync.control.start",
|
||||||
stop: "handoff.status_sync.control.stop",
|
stop: "task.status_sync.control.stop",
|
||||||
setInterval: "handoff.status_sync.control.set_interval",
|
setInterval: "task.status_sync.control.set_interval",
|
||||||
force: "handoff.status_sync.control.force",
|
force: "task.status_sync.control.force",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> {
|
async function pollSessionStatus(c: { state: TaskStatusSyncState }): Promise<void> {
|
||||||
const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId);
|
const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId);
|
||||||
const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId });
|
const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId });
|
||||||
|
|
||||||
const parent = getHandoff(c, c.state.workspaceId, c.state.repoId, c.state.handoffId);
|
const parent = getTask(c, c.state.workspaceId, c.state.repoId, c.state.taskId);
|
||||||
await parent.syncWorkbenchSessionStatus({
|
await parent.syncWorkbenchSessionStatus({
|
||||||
sessionId: c.state.sessionId,
|
sessionId: c.state.sessionId,
|
||||||
status: status.status,
|
status: status.status,
|
||||||
|
|
@ -47,7 +47,7 @@ async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handoffStatusSync = actor({
|
export const taskStatusSync = actor({
|
||||||
queues: {
|
queues: {
|
||||||
[CONTROL.start]: queue(),
|
[CONTROL.start]: queue(),
|
||||||
[CONTROL.stop]: queue(),
|
[CONTROL.stop]: queue(),
|
||||||
|
|
@ -58,10 +58,10 @@ export const handoffStatusSync = actor({
|
||||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||||
noSleep: true,
|
noSleep: true,
|
||||||
},
|
},
|
||||||
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
|
createState: (_c, input: TaskStatusSyncInput): TaskStatusSyncState => ({
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
repoId: input.repoId,
|
repoId: input.repoId,
|
||||||
handoffId: input.handoffId,
|
taskId: input.taskId,
|
||||||
providerId: input.providerId,
|
providerId: input.providerId,
|
||||||
sandboxId: input.sandboxId,
|
sandboxId: input.sandboxId,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
|
|
@ -70,34 +70,34 @@ export const handoffStatusSync = actor({
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async start(c): Promise<void> {
|
async start(c): Promise<void> {
|
||||||
const self = selfHandoffStatusSync(c);
|
const self = selfTaskStatusSync(c);
|
||||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
async stop(c): Promise<void> {
|
async stop(c): Promise<void> {
|
||||||
const self = selfHandoffStatusSync(c);
|
const self = selfTaskStatusSync(c);
|
||||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||||
const self = selfHandoffStatusSync(c);
|
const self = selfTaskStatusSync(c);
|
||||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
async force(c): Promise<void> {
|
async force(c): Promise<void> {
|
||||||
const self = selfHandoffStatusSync(c);
|
const self = selfTaskStatusSync(c);
|
||||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
run: workflow(async (ctx) => {
|
run: workflow(async (ctx) => {
|
||||||
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
|
await runWorkflowPollingLoop<TaskStatusSyncState>(ctx, {
|
||||||
loopName: "handoff-status-sync-loop",
|
loopName: "task-status-sync-loop",
|
||||||
control: CONTROL,
|
control: CONTROL,
|
||||||
onPoll: async (loopCtx) => {
|
onPoll: async (loopCtx) => {
|
||||||
try {
|
try {
|
||||||
await pollSessionStatus(loopCtx);
|
await pollSessionStatus(loopCtx);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logActorWarning("handoff-status-sync", "poll failed", {
|
logActorWarning("task-status-sync", "poll failed", {
|
||||||
error: resolveErrorMessage(error),
|
error: resolveErrorMessage(error),
|
||||||
stack: resolveErrorStack(error),
|
stack: resolveErrorStack(error),
|
||||||
});
|
});
|
||||||
5
foundry/packages/backend/src/actors/task/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/task/db/db.ts
Normal 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 });
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
CREATE TABLE `handoff` (
|
CREATE TABLE `task` (
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
`branch_name` text NOT NULL,
|
`branch_name` text NOT NULL,
|
||||||
`title` text NOT NULL,
|
`title` text NOT NULL,
|
||||||
|
|
@ -14,7 +14,7 @@ CREATE TABLE `handoff` (
|
||||||
`updated_at` integer NOT NULL
|
`updated_at` integer NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `handoff_runtime` (
|
CREATE TABLE `task_runtime` (
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
`sandbox_id` text,
|
`sandbox_id` text,
|
||||||
`session_id` text,
|
`session_id` text,
|
||||||
|
|
@ -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`;
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
ALTER TABLE `handoff_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
ALTER TABLE `task_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||||
ALTER TABLE `handoff_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
ALTER TABLE `task_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||||
ALTER TABLE `handoff_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
ALTER TABLE `task_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||||
CREATE TABLE `handoff_sandboxes` (
|
CREATE TABLE `task_sandboxes` (
|
||||||
`sandbox_id` text PRIMARY KEY NOT NULL,
|
`sandbox_id` text PRIMARY KEY NOT NULL,
|
||||||
`provider_id` text NOT NULL,
|
`provider_id` text NOT NULL,
|
||||||
`switch_target` text NOT NULL,
|
`switch_target` text NOT NULL,
|
||||||
|
|
@ -11,9 +11,9 @@ CREATE TABLE `handoff_sandboxes` (
|
||||||
`updated_at` integer NOT NULL
|
`updated_at` integer NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE `handoff_runtime` ADD `active_cwd` text;
|
ALTER TABLE `task_runtime` ADD `active_cwd` text;
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
INSERT INTO `handoff_sandboxes` (
|
INSERT INTO `task_sandboxes` (
|
||||||
`sandbox_id`,
|
`sandbox_id`,
|
||||||
`provider_id`,
|
`provider_id`,
|
||||||
`switch_target`,
|
`switch_target`,
|
||||||
|
|
@ -24,13 +24,13 @@ INSERT INTO `handoff_sandboxes` (
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
r.`active_sandbox_id`,
|
r.`active_sandbox_id`,
|
||||||
(SELECT h.`provider_id` FROM `handoff` h WHERE h.`id` = 1),
|
(SELECT h.`provider_id` FROM `task` h WHERE h.`id` = 1),
|
||||||
r.`active_switch_target`,
|
r.`active_switch_target`,
|
||||||
r.`active_cwd`,
|
r.`active_cwd`,
|
||||||
r.`status_message`,
|
r.`status_message`,
|
||||||
COALESCE((SELECT h.`created_at` FROM `handoff` h WHERE h.`id` = 1), r.`updated_at`),
|
COALESCE((SELECT h.`created_at` FROM `task` h WHERE h.`id` = 1), r.`updated_at`),
|
||||||
r.`updated_at`
|
r.`updated_at`
|
||||||
FROM `handoff_runtime` r
|
FROM `task_runtime` r
|
||||||
WHERE
|
WHERE
|
||||||
r.`id` = 1
|
r.`id` = 1
|
||||||
AND r.`active_sandbox_id` IS NOT NULL
|
AND r.`active_sandbox_id` IS NOT NULL
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
-- Allow handoffs to exist before their branch/title are determined.
|
-- Allow tasks to exist before their branch/title are determined.
|
||||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||||
|
|
||||||
PRAGMA foreign_keys=off;
|
PRAGMA foreign_keys=off;
|
||||||
|
|
||||||
CREATE TABLE `handoff__new` (
|
CREATE TABLE `task__new` (
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
`branch_name` text,
|
`branch_name` text,
|
||||||
`title` text,
|
`title` text,
|
||||||
|
|
@ -16,7 +16,7 @@ CREATE TABLE `handoff__new` (
|
||||||
`updated_at` integer NOT NULL
|
`updated_at` integer NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO `handoff__new` (
|
INSERT INTO `task__new` (
|
||||||
`id`,
|
`id`,
|
||||||
`branch_name`,
|
`branch_name`,
|
||||||
`title`,
|
`title`,
|
||||||
|
|
@ -39,10 +39,10 @@ SELECT
|
||||||
`pr_submitted`,
|
`pr_submitted`,
|
||||||
`created_at`,
|
`created_at`,
|
||||||
`updated_at`
|
`updated_at`
|
||||||
FROM `handoff`;
|
FROM `task`;
|
||||||
|
|
||||||
DROP TABLE `handoff`;
|
DROP TABLE `task`;
|
||||||
ALTER TABLE `handoff__new` RENAME TO `handoff`;
|
ALTER TABLE `task__new` RENAME TO `task`;
|
||||||
|
|
||||||
PRAGMA foreign_keys=on;
|
PRAGMA foreign_keys=on;
|
||||||
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
PRAGMA foreign_keys=off;
|
PRAGMA foreign_keys=off;
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `handoff__new`;
|
DROP TABLE IF EXISTS `task__new`;
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
||||||
CREATE TABLE `handoff__new` (
|
CREATE TABLE `task__new` (
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
`branch_name` text,
|
`branch_name` text,
|
||||||
`title` text,
|
`title` text,
|
||||||
|
|
@ -22,7 +22,7 @@ CREATE TABLE `handoff__new` (
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
||||||
INSERT INTO `handoff__new` (
|
INSERT INTO `task__new` (
|
||||||
`id`,
|
`id`,
|
||||||
`branch_name`,
|
`branch_name`,
|
||||||
`title`,
|
`title`,
|
||||||
|
|
@ -45,13 +45,13 @@ SELECT
|
||||||
`pr_submitted`,
|
`pr_submitted`,
|
||||||
`created_at`,
|
`created_at`,
|
||||||
`updated_at`
|
`updated_at`
|
||||||
FROM `handoff`;
|
FROM `task`;
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
||||||
DROP TABLE `handoff`;
|
DROP TABLE `task`;
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
||||||
ALTER TABLE `handoff__new` RENAME TO `handoff`;
|
ALTER TABLE `task__new` RENAME TO `task`;
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
||||||
PRAGMA foreign_keys=on;
|
PRAGMA foreign_keys=on;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `task_sandboxes` ADD `sandbox_actor_id` text;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
CREATE TABLE `handoff_workbench_sessions` (
|
CREATE TABLE `task_workbench_sessions` (
|
||||||
`session_id` text PRIMARY KEY NOT NULL,
|
`session_id` text PRIMARY KEY NOT NULL,
|
||||||
`session_name` text NOT NULL,
|
`session_name` text NOT NULL,
|
||||||
`model` text NOT NULL,
|
`model` text NOT NULL,
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue