mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
factory: rename project and handoff actors
This commit is contained in:
parent
3022bce2ad
commit
ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions
|
|
@ -19,30 +19,48 @@ Use `pnpm` workspaces and Turborepo.
|
|||
- Workspace root uses `pnpm-workspace.yaml` and `turbo.json`.
|
||||
- Packages live in `packages/*`.
|
||||
- `core` is renamed to `shared`.
|
||||
- `packages/cli` is disabled and excluded from active workspace validation.
|
||||
- Integrations and providers live under `packages/backend/src/{integrations,providers}`.
|
||||
|
||||
## CLI Status
|
||||
## Product Surface
|
||||
|
||||
- `packages/cli` is fully disabled for active development.
|
||||
- Do not implement new behavior in `packages/cli` unless explicitly requested.
|
||||
- The old CLI package has been removed.
|
||||
- Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`.
|
||||
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@sandbox-agent/factory-cli`.
|
||||
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
|
||||
|
||||
## Dev Server Policy
|
||||
|
||||
**Always use Docker Compose to run dev servers.** Do not start the backend, frontend, or any other long-running service directly via `bun`, `pnpm dev`, Vite, or tmux. All dev services must run through the Compose stack so that networking, environment variables, and service dependencies are consistent.
|
||||
|
||||
- Start the full dev stack (real backend): `just factory-dev`
|
||||
- Stop the dev stack: `just factory-dev-down`
|
||||
- Tail dev logs: `just factory-dev-logs`
|
||||
- Start the mock dev stack (frontend-only, no backend): `just factory-dev-mock`
|
||||
- Stop the mock stack: `just factory-dev-mock-down`
|
||||
- Tail mock logs: `just factory-dev-mock-logs`
|
||||
- Start the production-build preview stack: `just factory-preview`
|
||||
- Stop the preview stack: `just factory-preview-down`
|
||||
- Tail preview logs: `just factory-preview-logs`
|
||||
|
||||
The real dev server runs on port 4173 (frontend) + 7741 (backend). The mock dev server runs on port 4174 (frontend only). Both can run simultaneously.
|
||||
|
||||
When making code changes, restart or recreate the relevant Compose services so the running app reflects the latest code (e.g. `docker compose -f factory/compose.dev.yaml up -d --build backend`).
|
||||
|
||||
## Mock vs Real Backend — UI Change Policy
|
||||
|
||||
**When a user asks to make a UI change, always ask whether they are testing against the real backend or the mock backend before proceeding.**
|
||||
|
||||
- **Mock backend** (`compose.mock.yaml`, port 4174):
|
||||
- Only modify `packages/frontend`, `packages/client/src/mock/` (mock client implementation), and `packages/shared` (shared types/contracts).
|
||||
- Ignore typecheck/build errors in the real client (`packages/client/src/remote/`) and backend (`packages/backend`).
|
||||
- The assumption is that the mock server is the only test target; real backend compatibility is out of scope for that change.
|
||||
- **Real backend** (`compose.dev.yaml`, port 4173):
|
||||
- All layers must be kept in sync: `packages/frontend`, `packages/client` (both mock and remote), `packages/shared`, and `packages/backend`.
|
||||
- All typecheck, build, and test errors must be resolved across the full stack.
|
||||
|
||||
## Common Commands
|
||||
|
||||
- Install deps: `pnpm install`
|
||||
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
|
||||
- Start the full dev stack: `just factory-dev`
|
||||
- Start the local production-build preview stack: `just factory-preview`
|
||||
- Start only the backend locally: `just factory-backend-start`
|
||||
- Start only the frontend locally: `pnpm --filter @sandbox-agent/factory-frontend dev`
|
||||
- Start the frontend against the mock workbench client: `FACTORY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/factory-frontend dev`
|
||||
- Stop the compose dev stack: `just factory-dev-down`
|
||||
- Tail compose logs: `just factory-dev-logs`
|
||||
- Stop the preview stack: `just factory-preview-down`
|
||||
- Tail preview logs: `just factory-preview-logs`
|
||||
- Start the frontend against the mock workbench client (no backend needed): `FACTORY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/factory-frontend dev`
|
||||
|
||||
## Loading & Skeleton UI Policy
|
||||
|
||||
|
|
@ -57,7 +75,7 @@ Use `pnpm` workspaces and Turborepo.
|
|||
## Frontend + Client Boundary
|
||||
|
||||
- Keep a browser-friendly GUI implementation aligned with the TUI interaction model wherever possible.
|
||||
- Do not import `rivetkit` directly in CLI or GUI packages. RivetKit client access must stay isolated inside `packages/client`.
|
||||
- Do not import `rivetkit` directly in UI packages. RivetKit client access must stay isolated inside `packages/client`.
|
||||
- All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`.
|
||||
- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../api/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior.
|
||||
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
|
||||
|
|
@ -70,8 +88,8 @@ Use `pnpm` workspaces and Turborepo.
|
|||
## Runtime Policy
|
||||
|
||||
- Runtime is Bun-native.
|
||||
- Use Bun for CLI/backend execution paths and process spawning.
|
||||
- Do not add Node compatibility fallbacks for OpenTUI/runtime execution.
|
||||
- Use Bun for backend execution paths and process spawning.
|
||||
- Do not add Node compatibility fallbacks for removed CLI/OpenTUI paths.
|
||||
|
||||
## Defensive Error Handling
|
||||
|
||||
|
|
@ -91,18 +109,7 @@ For all Rivet/RivetKit implementation:
|
|||
- Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
|
||||
- Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys.
|
||||
3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`).
|
||||
4. Do not use published RivetKit npm packages.
|
||||
5. RivetKit is linked via pnpm `link:` protocol to `../rivet/rivetkit-typescript/packages/rivetkit`. Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the rivet workspace.
|
||||
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`
|
||||
- Dev worktree note: when working on RivetKit fixes for this repo, prefer the dedicated local checkout above and link to `../rivet-checkout/rivetkit-typescript/packages/rivetkit`.
|
||||
- If Docker dev needs a different host path, export `HF_RIVET_CHECKOUT_PATH=/abs/path/to/rivet-checkout` before `docker compose -f factory/compose.dev.yaml up`.
|
||||
6. Before using a fresh Rivet checkout, generate RivetKit schemas and build RivetKit in the rivet repo:
|
||||
```bash
|
||||
cd ../rivet-checkout/rivetkit-typescript
|
||||
pnpm install
|
||||
pnpm --dir packages/rivetkit build:schema
|
||||
pnpm build -F rivetkit
|
||||
```
|
||||
4. Use published RivetKit npm packages (`"rivetkit": "^2.1.6"` or later). Do not use `link:` dependencies pointing outside the workspace.
|
||||
|
||||
## Inspector HTTP API (Workflow Debugging)
|
||||
|
||||
|
|
@ -141,10 +148,14 @@ For all Rivet/RivetKit implementation:
|
|||
## Workspace + Actor Rules
|
||||
|
||||
- Everything is scoped to a workspace.
|
||||
- All durable Factory data must live inside actors.
|
||||
- App-shell/auth/session/org/billing data is actor-owned data too; do not introduce backend-global stores for it.
|
||||
- Do not add standalone SQLite files, JSON stores, in-memory singleton stores, or any other non-actor persistence for Factory product state.
|
||||
- If data needs durable persistence, store it in actor `c.state` or the owning actor's SQLite DB via `c.db`.
|
||||
- Workspace resolution order: `--workspace` flag -> config default -> `"default"`.
|
||||
- `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator).
|
||||
- Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`).
|
||||
- CLI/TUI/GUI must use `@sandbox-agent/factory-client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`.
|
||||
- Product surfaces must use `@sandbox-agent/factory-client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`.
|
||||
- Do not add custom backend REST endpoints (no `/v1/*` shim layer).
|
||||
- We own the sandbox-agent project; treat sandbox-agent defects as first-party bugs and fix them instead of working around them.
|
||||
- Keep strict single-writer ownership: each table/row has exactly one actor writer.
|
||||
|
|
@ -239,4 +250,4 @@ pnpm -w build
|
|||
pnpm -w test
|
||||
```
|
||||
|
||||
After making code changes, always update the dev server before declaring the work complete. If the dev stack is running through Docker Compose, restart or recreate the relevant dev services so the running app reflects the latest code.
|
||||
After making code changes, always update the dev server before declaring the work complete. Restart or recreate the relevant Docker Compose services so the running app reflects the latest code. Do not run dev servers outside of Docker Compose.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pnpm -w build
|
|||
|
||||
- `packages/shared`: contracts/schemas
|
||||
- `packages/backend`: RivetKit actors + DB + providers + integrations
|
||||
- `packages/cli`: `hf` and `hf tui` (OpenTUI)
|
||||
- `packages/frontend`: primary UI surface
|
||||
|
||||
## Local RivetKit Dependency
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Sandbox Agent Factory
|
||||
|
||||
TypeScript workspace handoff system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI.
|
||||
TypeScript workspace task system powered by RivetKit actors and SQLite/Drizzle state.
|
||||
|
||||
## Quick Install
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ pnpm -w build
|
|||
- **Simple**: There's one screen. It has everything you need. You can use it blindfolded.
|
||||
- **Fast**: No waiting around.
|
||||
- **Collaborative**: Built for fast moving teams that need code reviewed & shipped fast.
|
||||
- **Pluggable**: Works for small side projects to enterprise teams.
|
||||
- **Pluggable**: Works for small side projects to growing teams.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -2,22 +2,26 @@ import type { AppConfig } from "@sandbox-agent/factory-shared";
|
|||
import type { BackendDriver } from "../driver.js";
|
||||
import type { NotificationService } from "../notifications/index.js";
|
||||
import type { ProviderRegistry } from "../providers/index.js";
|
||||
import type { AppShellServices } from "../services/app-shell-runtime.js";
|
||||
|
||||
let runtimeConfig: AppConfig | null = null;
|
||||
let providerRegistry: ProviderRegistry | null = null;
|
||||
let notificationService: NotificationService | null = null;
|
||||
let runtimeDriver: BackendDriver | null = null;
|
||||
let appShellServices: AppShellServices | null = null;
|
||||
|
||||
export function initActorRuntimeContext(
|
||||
config: AppConfig,
|
||||
providers: ProviderRegistry,
|
||||
notifications?: NotificationService,
|
||||
driver?: BackendDriver
|
||||
driver?: BackendDriver,
|
||||
appShell?: AppShellServices
|
||||
): void {
|
||||
runtimeConfig = config;
|
||||
providerRegistry = providers;
|
||||
notificationService = notifications ?? null;
|
||||
runtimeDriver = driver ?? null;
|
||||
appShellServices = appShell ?? null;
|
||||
}
|
||||
|
||||
export function getActorRuntimeContext(): {
|
||||
|
|
@ -25,6 +29,7 @@ export function getActorRuntimeContext(): {
|
|||
providers: ProviderRegistry;
|
||||
notifications: NotificationService | null;
|
||||
driver: BackendDriver;
|
||||
appShell: AppShellServices;
|
||||
} {
|
||||
if (!runtimeConfig || !providerRegistry) {
|
||||
throw new Error("Actor runtime context not initialized");
|
||||
|
|
@ -34,10 +39,15 @@ export function getActorRuntimeContext(): {
|
|||
throw new Error("Actor runtime context missing driver");
|
||||
}
|
||||
|
||||
if (!appShellServices) {
|
||||
throw new Error("Actor runtime context missing app shell services");
|
||||
}
|
||||
|
||||
return {
|
||||
config: runtimeConfig,
|
||||
providers: providerRegistry,
|
||||
notifications: notificationService,
|
||||
driver: runtimeDriver,
|
||||
appShell: appShellServices,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import type { HandoffStatus, ProviderId } from "@sandbox-agent/factory-shared";
|
||||
import type { TaskStatus, ProviderId } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export interface HandoffCreatedEvent {
|
||||
export interface TaskCreatedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
providerId: ProviderId;
|
||||
branchName: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface HandoffStatusEvent {
|
||||
export interface TaskStatusEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
status: HandoffStatus;
|
||||
taskId: string;
|
||||
status: TaskStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectSnapshotEvent {
|
||||
export interface RepoSnapshotEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
updatedAt: number;
|
||||
|
|
@ -26,28 +26,28 @@ export interface ProjectSnapshotEvent {
|
|||
export interface AgentStartedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentIdleEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentErrorEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PrCreatedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
url: string;
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ export interface PrCreatedEvent {
|
|||
export interface PrClosedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
merged: boolean;
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ export interface PrClosedEvent {
|
|||
export interface PrReviewEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
reviewer: string;
|
||||
status: string;
|
||||
|
|
@ -72,41 +72,41 @@ export interface PrReviewEvent {
|
|||
export interface CiStatusChangedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type HandoffStepName = "auto_commit" | "push" | "pr_submit";
|
||||
export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed";
|
||||
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
|
||||
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
|
||||
|
||||
export interface HandoffStepEvent {
|
||||
export interface TaskStepEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
step: HandoffStepName;
|
||||
status: HandoffStepStatus;
|
||||
taskId: string;
|
||||
step: TaskStepName;
|
||||
status: TaskStepStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BranchSwitchedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
export interface SessionAttachedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface BranchSyncedEvent {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
branchName: string;
|
||||
strategy: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import {
|
||||
handoffKey,
|
||||
handoffStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
repoBranchSyncKey,
|
||||
repoKey,
|
||||
repoPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
workspaceKey,
|
||||
} from "./keys.js";
|
||||
import type { ProviderId } from "@sandbox-agent/factory-shared";
|
||||
|
||||
|
|
@ -16,37 +16,36 @@ export function actorClient(c: any) {
|
|||
|
||||
export async function getOrCreateWorkspace(c: any, workspaceId: string) {
|
||||
return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
createWithInput: workspaceId
|
||||
createWithInput: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
|
||||
return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), {
|
||||
export async function getOrCreateRepo(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
|
||||
return await actorClient(c).repo.getOrCreate(repoKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
remoteUrl
|
||||
}
|
||||
remoteUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getProject(c: any, workspaceId: string, repoId: string) {
|
||||
return actorClient(c).project.get(projectKey(workspaceId, repoId));
|
||||
export function getRepo(c: any, workspaceId: string, repoId: string) {
|
||||
return actorClient(c).repo.get(repoKey(workspaceId, repoId));
|
||||
}
|
||||
|
||||
export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) {
|
||||
return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
|
||||
export function getTask(c: any, workspaceId: string, taskId: string) {
|
||||
return actorClient(c).task.get(taskKey(workspaceId, taskId));
|
||||
}
|
||||
|
||||
export async function getOrCreateHandoff(
|
||||
export async function getOrCreateTask(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
createWithInput: Record<string, unknown>
|
||||
taskId: string,
|
||||
createWithInput: Record<string, unknown>,
|
||||
) {
|
||||
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), {
|
||||
createWithInput
|
||||
return await actorClient(c).task.getOrCreate(taskKey(workspaceId, taskId), {
|
||||
createWithInput,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -54,42 +53,42 @@ export async function getOrCreateHistory(c: any, workspaceId: string, repoId: st
|
|||
return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId
|
||||
}
|
||||
repoId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectPrSync(
|
||||
export async function getOrCreateRepoPrSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
intervalMs: number
|
||||
intervalMs: number,
|
||||
) {
|
||||
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
|
||||
return await actorClient(c).repoPrSync.getOrCreate(repoPrSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs
|
||||
}
|
||||
intervalMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectBranchSync(
|
||||
export async function getOrCreateRepoBranchSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
intervalMs: number
|
||||
intervalMs: number,
|
||||
) {
|
||||
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
||||
return await actorClient(c).repoBranchSync.getOrCreate(repoBranchSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs
|
||||
}
|
||||
intervalMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -102,57 +101,57 @@ export async function getOrCreateSandboxInstance(
|
|||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
createWithInput: Record<string, unknown>
|
||||
createWithInput: Record<string, unknown>,
|
||||
) {
|
||||
return await actorClient(c).sandboxInstance.getOrCreate(
|
||||
sandboxInstanceKey(workspaceId, providerId, sandboxId),
|
||||
{ createWithInput }
|
||||
{ createWithInput },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateHandoffStatusSync(
|
||||
export async function getOrCreateTaskStatusSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
taskId: string,
|
||||
sandboxId: 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function selfProjectPrSync(c: any) {
|
||||
return actorClient(c).projectPrSync.getForId(c.actorId);
|
||||
export function selfRepoPrSync(c: any) {
|
||||
return actorClient(c).repoPrSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfProjectBranchSync(c: any) {
|
||||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||
export function selfRepoBranchSync(c: any) {
|
||||
return actorClient(c).repoBranchSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHandoffStatusSync(c: any) {
|
||||
return actorClient(c).handoffStatusSync.getForId(c.actorId);
|
||||
export function selfTaskStatusSync(c: any) {
|
||||
return actorClient(c).taskStatusSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHistory(c: any) {
|
||||
return actorClient(c).history.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHandoff(c: any) {
|
||||
return actorClient(c).handoff.getForId(c.actorId);
|
||||
export function selfTask(c: any) {
|
||||
return actorClient(c).task.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfWorkspace(c: any) {
|
||||
return actorClient(c).workspace.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfProject(c: any) {
|
||||
return actorClient(c).project.getForId(c.actorId);
|
||||
export function selfRepo(c: any) {
|
||||
return actorClient(c).repo.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfSandboxInstance(c: any) {
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/handoff/db/drizzle",
|
||||
schema: "./src/actors/handoff/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type {
|
||||
AgentType,
|
||||
HandoffRecord,
|
||||
HandoffWorkbenchChangeModelInput,
|
||||
HandoffWorkbenchRenameInput,
|
||||
HandoffWorkbenchRenameSessionInput,
|
||||
HandoffWorkbenchSetSessionUnreadInput,
|
||||
HandoffWorkbenchSendMessageInput,
|
||||
HandoffWorkbenchUpdateDraftInput,
|
||||
ProviderId
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { selfHandoff } from "../handles.js";
|
||||
import { handoffDb } from "./db/db.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
getWorkbenchHandoff,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchHandoff,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
syncWorkbenchSessionStatus,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
updateWorkbenchDraft
|
||||
} from "./workbench.js";
|
||||
import {
|
||||
HANDOFF_QUEUE_NAMES,
|
||||
handoffWorkflowQueueName,
|
||||
runHandoffWorkflow
|
||||
} from "./workflow/index.js";
|
||||
|
||||
export interface HandoffInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
repoRemote: string;
|
||||
repoLocalPath: string;
|
||||
branchName: string | null;
|
||||
title: string | null;
|
||||
task: string;
|
||||
providerId: ProviderId;
|
||||
agentType: AgentType | null;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
}
|
||||
|
||||
interface InitializeCommand {
|
||||
providerId?: ProviderId;
|
||||
}
|
||||
|
||||
interface HandoffActionCommand {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface HandoffTabCommand {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface HandoffStatusSyncCommand {
|
||||
sessionId: string;
|
||||
status: "running" | "idle" | "error";
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchValueCommand {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSessionTitleCommand {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSessionUnreadCommand {
|
||||
sessionId: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchUpdateDraftCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchChangeModelCommand {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSendMessageCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface HandoffWorkbenchSessionCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const handoff = actor({
|
||||
db: handoffDb,
|
||||
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000
|
||||
},
|
||||
createState: (_c, input: HandoffInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
handoffId: input.handoffId,
|
||||
repoRemote: input.repoRemote,
|
||||
repoLocalPath: input.repoLocalPath,
|
||||
branchName: input.branchName,
|
||||
title: input.title,
|
||||
task: input.task,
|
||||
providerId: input.providerId,
|
||||
agentType: input.agentType,
|
||||
explicitTitle: input.explicitTitle,
|
||||
explicitBranchName: input.explicitBranchName,
|
||||
initialized: false,
|
||||
previousStatus: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async initialize(c, cmd: InitializeCommand): Promise<HandoffRecord> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
return expectQueueResponse<HandoffRecord>(result);
|
||||
},
|
||||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30 * 60_000,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
});
|
||||
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
|
||||
},
|
||||
|
||||
async switch(c): Promise<{ switchTarget: string }> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.switch"), {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
});
|
||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
||||
},
|
||||
|
||||
async push(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 180_000
|
||||
});
|
||||
},
|
||||
|
||||
async sync(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
});
|
||||
},
|
||||
|
||||
async merge(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
});
|
||||
},
|
||||
|
||||
async archive(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
void self
|
||||
.send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
c.log.warn({
|
||||
msg: "archive command failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async kill(c, cmd?: HandoffActionCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000
|
||||
});
|
||||
},
|
||||
|
||||
async get(c): Promise<HandoffRecord> {
|
||||
return await getCurrentRecord({ db: c.db, state: c.state });
|
||||
},
|
||||
|
||||
async getWorkbench(c) {
|
||||
return await getWorkbenchHandoff(c);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), {}, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"),
|
||||
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.rename_branch"),
|
||||
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
|
||||
const self = selfHandoff(c);
|
||||
const result = await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.create_session"),
|
||||
{ ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ tabId: string }>(result);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.rename_session"),
|
||||
{ sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"),
|
||||
{ sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.update_draft"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies HandoffWorkbenchUpdateDraftCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.change_model"),
|
||||
{ sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies HandoffWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.stop_session"),
|
||||
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"),
|
||||
input,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.close_session"),
|
||||
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(c): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, {
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.workbench.revert_file"),
|
||||
input,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
run: workflow(runHandoffWorkflow)
|
||||
});
|
||||
|
||||
export { HANDOFF_QUEUE_NAMES };
|
||||
|
|
@ -1,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;
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export default {
|
|||
migrations: {
|
||||
m0000: `CREATE TABLE \`events\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`handoff_id\` text,
|
||||
\`task_id\` text,
|
||||
\`branch_name\` text,
|
||||
\`kind\` 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", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
handoffId: text("handoff_id"),
|
||||
taskId: text("task_id"),
|
||||
branchName: text("branch_name"),
|
||||
kind: text("kind").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ export interface HistoryInput {
|
|||
|
||||
export interface AppendHistoryCommand {
|
||||
kind: string;
|
||||
handoffId?: string;
|
||||
taskId?: string;
|
||||
branchName?: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListHistoryParams {
|
||||
branch?: string;
|
||||
handoffId?: string;
|
||||
taskId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi
|
|||
await loopCtx.db
|
||||
.insert(events)
|
||||
.values({
|
||||
handoffId: body.handoffId ?? null,
|
||||
taskId: body.taskId ?? null,
|
||||
branchName: body.branchName ?? null,
|
||||
kind: body.kind,
|
||||
payloadJson: JSON.stringify(body.payload),
|
||||
|
|
@ -77,8 +77,8 @@ export const history = actor({
|
|||
|
||||
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
|
||||
const whereParts = [];
|
||||
if (params?.handoffId) {
|
||||
whereParts.push(eq(events.handoffId, params.handoffId));
|
||||
if (params?.taskId) {
|
||||
whereParts.push(eq(events.taskId, params.taskId));
|
||||
}
|
||||
if (params?.branch) {
|
||||
whereParts.push(eq(events.branchName, params.branch));
|
||||
|
|
@ -87,7 +87,7 @@ export const history = actor({
|
|||
const base = c.db
|
||||
.select({
|
||||
id: events.id,
|
||||
handoffId: events.handoffId,
|
||||
taskId: events.taskId,
|
||||
branchName: events.branchName,
|
||||
kind: events.kind,
|
||||
payloadJson: events.payloadJson,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { setup } from "rivetkit";
|
||||
import { handoffStatusSync } from "./handoff-status-sync/index.js";
|
||||
import { handoff } from "./handoff/index.js";
|
||||
import { taskStatusSync } from "./task-status-sync/index.js";
|
||||
import { task } from "./task/index.js";
|
||||
import { history } from "./history/index.js";
|
||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
||||
import { project } from "./project/index.js";
|
||||
import { repoBranchSync } from "./repo-branch-sync/index.js";
|
||||
import { repoPrSync } from "./repo-pr-sync/index.js";
|
||||
import { repo } from "./repo/index.js";
|
||||
import { sandboxInstance } from "./sandbox-instance/index.js";
|
||||
import { workspace } from "./workspace/index.js";
|
||||
|
||||
|
|
@ -29,13 +29,13 @@ export function resolveManagerHost(): string {
|
|||
export const registry = setup({
|
||||
use: {
|
||||
workspace,
|
||||
project,
|
||||
handoff,
|
||||
repo,
|
||||
task,
|
||||
sandboxInstance,
|
||||
history,
|
||||
projectPrSync,
|
||||
projectBranchSync,
|
||||
handoffStatusSync
|
||||
repoPrSync,
|
||||
repoBranchSync,
|
||||
taskStatusSync
|
||||
},
|
||||
managerPort: resolveManagerPort(),
|
||||
managerHost: resolveManagerHost()
|
||||
|
|
@ -43,12 +43,12 @@ export const registry = setup({
|
|||
|
||||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./handoff-status-sync/index.js";
|
||||
export * from "./handoff/index.js";
|
||||
export * from "./task-status-sync/index.js";
|
||||
export * from "./task/index.js";
|
||||
export * from "./history/index.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./project-branch-sync/index.js";
|
||||
export * from "./project-pr-sync/index.js";
|
||||
export * from "./project/index.js";
|
||||
export * from "./repo-branch-sync/index.js";
|
||||
export * from "./repo-pr-sync/index.js";
|
||||
export * from "./repo/index.js";
|
||||
export * from "./sandbox-instance/index.js";
|
||||
export * from "./workspace/index.js";
|
||||
|
|
|
|||
|
|
@ -4,41 +4,41 @@ export function workspaceKey(workspaceId: string): ActorKey {
|
|||
return ["ws", workspaceId];
|
||||
}
|
||||
|
||||
export function projectKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId];
|
||||
export function repoKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "repo", repoId];
|
||||
}
|
||||
|
||||
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
|
||||
export function taskKey(workspaceId: string, taskId: string): ActorKey {
|
||||
return ["ws", workspaceId, "task", taskId];
|
||||
}
|
||||
|
||||
export function sandboxInstanceKey(
|
||||
workspaceId: string,
|
||||
providerId: string,
|
||||
sandboxId: string
|
||||
sandboxId: string,
|
||||
): ActorKey {
|
||||
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "history"];
|
||||
return ["ws", workspaceId, "repo", repoId, "history"];
|
||||
}
|
||||
|
||||
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "pr-sync"];
|
||||
export function repoPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "repo", repoId, "pr-sync"];
|
||||
}
|
||||
|
||||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||
export function repoBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "repo", repoId, "branch-sync"];
|
||||
}
|
||||
|
||||
export function handoffStatusSyncKey(
|
||||
export function taskStatusSyncKey(
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
taskId: string,
|
||||
sandboxId: string,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
): ActorKey {
|
||||
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
|
||||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
|
||||
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
|
||||
return ["ws", workspaceId, "task", taskId, "status-sync", repoId, sandboxId, sessionId];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/project/db/drizzle",
|
||||
schema: "./src/actors/project/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { projectDb } from "./db/db.js";
|
||||
import { PROJECT_QUEUE_NAMES, projectActions, runProjectWorkflow } from "./actions.js";
|
||||
|
||||
export interface ProjectInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
remoteUrl: string;
|
||||
}
|
||||
|
||||
export const project = actor({
|
||||
db: projectDb,
|
||||
queues: Object.fromEntries(PROJECT_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: ProjectInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
localPath: null as string | null,
|
||||
syncActorsStarted: false,
|
||||
handoffIndexHydrated: false
|
||||
}),
|
||||
actions: projectActions,
|
||||
run: workflow(runProjectWorkflow),
|
||||
});
|
||||
|
|
@ -2,13 +2,13 @@ import { actor, queue } from "rivetkit";
|
|||
import { workflow } from "rivetkit/workflow";
|
||||
import type { GitDriver } from "../../driver.js";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getProject, selfProjectBranchSync } from "../handles.js";
|
||||
import { getRepo, selfRepoBranchSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
import { parentLookupFromStack } from "../project/stack-model.js";
|
||||
import { parentLookupFromStack } from "../repo/stack-model.js";
|
||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||
|
||||
export interface ProjectBranchSyncInput {
|
||||
export interface RepoBranchSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
|
|
@ -29,17 +29,17 @@ interface EnrichedBranchSnapshot {
|
|||
conflictsWithMain: boolean;
|
||||
}
|
||||
|
||||
interface ProjectBranchSyncState extends PollingControlState {
|
||||
interface RepoBranchSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "project.branch_sync.control.start",
|
||||
stop: "project.branch_sync.control.stop",
|
||||
setInterval: "project.branch_sync.control.set_interval",
|
||||
force: "project.branch_sync.control.force"
|
||||
start: "repo.branch_sync.control.start",
|
||||
stop: "repo.branch_sync.control.stop",
|
||||
setInterval: "repo.branch_sync.control.set_interval",
|
||||
force: "repo.branch_sync.control.force"
|
||||
} as const;
|
||||
|
||||
async function enrichBranches(
|
||||
|
|
@ -67,7 +67,7 @@ async function enrichBranches(
|
|||
try {
|
||||
branchDiffStat = await git.diffStatForBranch(repoPath, branch.branchName);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "diffStatForBranch failed", {
|
||||
logActorWarning("repo-branch-sync", "diffStatForBranch failed", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
|
|
@ -80,7 +80,7 @@ async function enrichBranches(
|
|||
const headSha = await git.revParse(repoPath, `origin/${branch.branchName}`);
|
||||
branchHasUnpushed = Boolean(baseSha && headSha && headSha !== baseSha);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "revParse failed", {
|
||||
logActorWarning("repo-branch-sync", "revParse failed", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
|
|
@ -92,7 +92,7 @@ async function enrichBranches(
|
|||
try {
|
||||
branchConflicts = await git.conflictsWithMain(repoPath, branch.branchName);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "conflictsWithMain failed", {
|
||||
logActorWarning("repo-branch-sync", "conflictsWithMain failed", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
|
|
@ -116,14 +116,14 @@ async function enrichBranches(
|
|||
});
|
||||
}
|
||||
|
||||
async function pollBranches(c: { state: ProjectBranchSyncState }): Promise<void> {
|
||||
async function pollBranches(c: { state: RepoBranchSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const enrichedItems = await enrichBranches(c.state.workspaceId, c.state.repoId, c.state.repoPath, driver.git);
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
const parent = getRepo(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyBranchSyncResult({ items: enrichedItems, at: Date.now() });
|
||||
}
|
||||
|
||||
export const projectBranchSync = actor({
|
||||
export const repoBranchSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
|
|
@ -134,7 +134,7 @@ export const projectBranchSync = actor({
|
|||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
},
|
||||
createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({
|
||||
createState: (_c, input: RepoBranchSyncInput): RepoBranchSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
|
|
@ -143,34 +143,34 @@ export const projectBranchSync = actor({
|
|||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
const self = selfRepoBranchSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
const self = selfRepoBranchSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
const self = selfRepoBranchSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
const self = selfRepoBranchSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, {
|
||||
loopName: "project-branch-sync-loop",
|
||||
await runWorkflowPollingLoop<RepoBranchSyncState>(ctx, {
|
||||
loopName: "repo-branch-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollBranches(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "poll failed", {
|
||||
logActorWarning("repo-branch-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
});
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getProject, selfProjectPrSync } from "../handles.js";
|
||||
import { getRepo, selfRepoPrSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
|
||||
export interface ProjectPrSyncInput {
|
||||
export interface RepoPrSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
|
|
@ -16,27 +16,27 @@ interface SetIntervalCommand {
|
|||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface ProjectPrSyncState extends PollingControlState {
|
||||
interface RepoPrSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "project.pr_sync.control.start",
|
||||
stop: "project.pr_sync.control.stop",
|
||||
setInterval: "project.pr_sync.control.set_interval",
|
||||
force: "project.pr_sync.control.force"
|
||||
start: "repo.pr_sync.control.start",
|
||||
stop: "repo.pr_sync.control.stop",
|
||||
setInterval: "repo.pr_sync.control.set_interval",
|
||||
force: "repo.pr_sync.control.force"
|
||||
} as const;
|
||||
|
||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
||||
async function pollPrs(c: { state: RepoPrSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const items = await driver.github.listPullRequests(c.state.repoPath);
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
const parent = getRepo(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyPrSyncResult({ items, at: Date.now() });
|
||||
}
|
||||
|
||||
export const projectPrSync = actor({
|
||||
export const repoPrSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
|
|
@ -47,7 +47,7 @@ export const projectPrSync = actor({
|
|||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
},
|
||||
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
|
||||
createState: (_c, input: RepoPrSyncInput): RepoPrSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
|
|
@ -56,34 +56,34 @@ export const projectPrSync = actor({
|
|||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
const self = selfRepoPrSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
const self = selfRepoPrSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
const self = selfRepoPrSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
const self = selfRepoPrSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
|
||||
loopName: "project-pr-sync-loop",
|
||||
await runWorkflowPollingLoop<RepoPrSyncState>(ctx, {
|
||||
loopName: "repo-pr-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollPrs(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("project-pr-sync", "poll failed", {
|
||||
logActorWarning("repo-pr-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,8 +2,8 @@ import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const projectDb = actorSqliteDb({
|
||||
actorName: "project",
|
||||
export const repoDb = actorSqliteDb({
|
||||
actorName: "repo",
|
||||
schema,
|
||||
migrations,
|
||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/repo/db/drizzle",
|
||||
schema: "./src/actors/repo/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
CREATE TABLE `handoff_index` (
|
||||
CREATE TABLE `task_index` (
|
||||
`handoff_id` text PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`created_at` integer NOT NULL,
|
||||
|
|
@ -69,8 +69,8 @@ CREATE TABLE \`pr_cache\` (
|
|||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
|
||||
m0002: `CREATE TABLE \`handoff_index\` (
|
||||
\`handoff_id\` text PRIMARY KEY NOT NULL,
|
||||
m0002: `CREATE TABLE \`task_index\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per project actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed.
|
||||
// SQLite is per repo actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed.
|
||||
|
||||
export const branches = sqliteTable("branches", {
|
||||
branchName: text("branch_name").notNull().primaryKey(),
|
||||
|
|
@ -36,8 +36,8 @@ export const prCache = sqliteTable("pr_cache", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffIndex = sqliteTable("handoff_index", {
|
||||
handoffId: text("handoff_id").notNull().primaryKey(),
|
||||
export const taskIndex = sqliteTable("task_index", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull()
|
||||
28
factory/packages/backend/src/actors/repo/index.ts
Normal file
28
factory/packages/backend/src/actors/repo/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { repoDb } from "./db/db.js";
|
||||
import { REPO_QUEUE_NAMES, repoActions, runRepoWorkflow } from "./actions.js";
|
||||
|
||||
export interface RepoInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
remoteUrl: string;
|
||||
}
|
||||
|
||||
export const repo = actor({
|
||||
db: repoDb,
|
||||
queues: Object.fromEntries(REPO_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: RepoInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
localPath: null as string | null,
|
||||
syncActorsStarted: false,
|
||||
taskIndexHydrated: false
|
||||
}),
|
||||
actions: repoActions,
|
||||
run: workflow(runRepoWorkflow),
|
||||
});
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type { ProviderId } from "@sandbox-agent/factory-shared";
|
||||
import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js";
|
||||
import { getTask, getSandboxInstance, selfTaskStatusSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
|
||||
export interface HandoffStatusSyncInput {
|
||||
export interface TaskStatusSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
|
|
@ -19,27 +19,27 @@ interface SetIntervalCommand {
|
|||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface HandoffStatusSyncState extends PollingControlState {
|
||||
interface TaskStatusSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "handoff.status_sync.control.start",
|
||||
stop: "handoff.status_sync.control.stop",
|
||||
setInterval: "handoff.status_sync.control.set_interval",
|
||||
force: "handoff.status_sync.control.force"
|
||||
start: "task.status_sync.control.start",
|
||||
stop: "task.status_sync.control.stop",
|
||||
setInterval: "task.status_sync.control.set_interval",
|
||||
force: "task.status_sync.control.force"
|
||||
} as const;
|
||||
|
||||
async function pollSessionStatus(c: { state: 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 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.taskId);
|
||||
await parent.syncWorkbenchSessionStatus({
|
||||
sessionId: c.state.sessionId,
|
||||
status: status.status,
|
||||
|
|
@ -47,7 +47,7 @@ async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<
|
|||
});
|
||||
}
|
||||
|
||||
export const handoffStatusSync = actor({
|
||||
export const taskStatusSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: 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.
|
||||
noSleep: true
|
||||
},
|
||||
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
|
||||
createState: (_c, input: TaskStatusSyncInput): TaskStatusSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
handoffId: input.handoffId,
|
||||
taskId: input.taskId,
|
||||
providerId: input.providerId,
|
||||
sandboxId: input.sandboxId,
|
||||
sessionId: input.sessionId,
|
||||
|
|
@ -70,34 +70,34 @@ export const handoffStatusSync = actor({
|
|||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
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 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
|
||||
loopName: "handoff-status-sync-loop",
|
||||
await runWorkflowPollingLoop<TaskStatusSyncState>(ctx, {
|
||||
loopName: "task-status-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollSessionStatus(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff-status-sync", "poll failed", {
|
||||
logActorWarning("task-status-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@ import { actorSqliteDb } from "../../../db/actor-sqlite.js";
|
|||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const handoffDb = actorSqliteDb({
|
||||
actorName: "handoff",
|
||||
export const taskDb = actorSqliteDb({
|
||||
actorName: "task",
|
||||
schema,
|
||||
migrations,
|
||||
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/task/db/drizzle",
|
||||
schema: "./src/actors/task/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ const journal = {
|
|||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`handoff\` (
|
||||
m0000: `CREATE TABLE \`task\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
|
|
@ -68,7 +68,7 @@ export default {
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`handoff_runtime\` (
|
||||
CREATE TABLE \`task_runtime\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`sandbox_id\` text,
|
||||
\`session_id\` text,
|
||||
|
|
@ -77,13 +77,13 @@ CREATE TABLE \`handoff_runtime\` (
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `ALTER TABLE \`handoff\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`handoff\` DROP COLUMN \`pushed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`handoff\` DROP COLUMN \`needs_push\`;`,
|
||||
m0002: `ALTER TABLE \`handoff_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE \`handoff_sandboxes\` (
|
||||
m0001: `ALTER TABLE \`task\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`task\` DROP COLUMN \`pushed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`task\` DROP COLUMN \`needs_push\`;`,
|
||||
m0002: `ALTER TABLE \`task_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE \`task_sandboxes\` (
|
||||
\`sandbox_id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`switch_target\` text NOT NULL,
|
||||
|
|
@ -93,9 +93,9 @@ CREATE TABLE \`handoff_sandboxes\` (
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`handoff_runtime\` ADD \`active_cwd\` text;
|
||||
ALTER TABLE \`task_runtime\` ADD \`active_cwd\` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO \`handoff_sandboxes\` (
|
||||
INSERT INTO \`task_sandboxes\` (
|
||||
\`sandbox_id\`,
|
||||
\`provider_id\`,
|
||||
\`switch_target\`,
|
||||
|
|
@ -106,25 +106,25 @@ INSERT INTO \`handoff_sandboxes\` (
|
|||
)
|
||||
SELECT
|
||||
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_cwd\`,
|
||||
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\`
|
||||
FROM \`handoff_runtime\` r
|
||||
FROM \`task_runtime\` r
|
||||
WHERE
|
||||
r.\`id\` = 1
|
||||
AND r.\`active_sandbox_id\` IS NOT NULL
|
||||
AND r.\`active_switch_target\` IS NOT NULL
|
||||
ON CONFLICT(\`sandbox_id\`) DO NOTHING;
|
||||
`,
|
||||
m0003: `-- Allow handoffs to exist before their branch/title are determined.
|
||||
m0003: `-- Allow tasks to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE \`handoff__new\` (
|
||||
CREATE TABLE \`task__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
|
|
@ -137,7 +137,7 @@ CREATE TABLE \`handoff__new\` (
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO \`handoff__new\` (
|
||||
INSERT INTO \`task__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
|
|
@ -160,10 +160,10 @@ SELECT
|
|||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`handoff\`;
|
||||
FROM \`task\`;
|
||||
|
||||
DROP TABLE \`handoff\`;
|
||||
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
|
||||
DROP TABLE \`task\`;
|
||||
ALTER TABLE \`task__new\` RENAME TO \`task\`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
|
|
@ -175,10 +175,10 @@ PRAGMA foreign_keys=on;
|
|||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS \`handoff__new\`;
|
||||
DROP TABLE IF EXISTS \`task__new\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE \`handoff__new\` (
|
||||
CREATE TABLE \`task__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
|
|
@ -192,7 +192,7 @@ CREATE TABLE \`handoff__new\` (
|
|||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO \`handoff__new\` (
|
||||
INSERT INTO \`task__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
|
|
@ -215,19 +215,19 @@ SELECT
|
|||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`handoff\`;
|
||||
FROM \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE \`handoff\`;
|
||||
DROP TABLE \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
|
||||
ALTER TABLE \`task__new\` RENAME TO \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
`,
|
||||
m0005: `ALTER TABLE \`handoff_sandboxes\` ADD \`sandbox_actor_id\` text;`,
|
||||
m0006: `CREATE TABLE \`handoff_workbench_sessions\` (
|
||||
m0005: `ALTER TABLE \`task_sandboxes\` ADD \`sandbox_actor_id\` text;`,
|
||||
m0006: `CREATE TABLE \`task_workbench_sessions\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_name\` text NOT NULL,
|
||||
\`model\` text NOT NULL,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per handoff actor instance, so these tables only ever store one row (id=1).
|
||||
export const handoff = sqliteTable("handoff", {
|
||||
// SQLite is per task actor instance, so these tables only ever store one row (id=1).
|
||||
export const task = sqliteTable("task", {
|
||||
id: integer("id").primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
title: text("title"),
|
||||
|
|
@ -14,7 +14,7 @@ export const handoff = sqliteTable("handoff", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffRuntime = sqliteTable("handoff_runtime", {
|
||||
export const taskRuntime = sqliteTable("task_runtime", {
|
||||
id: integer("id").primaryKey(),
|
||||
activeSandboxId: text("active_sandbox_id"),
|
||||
activeSessionId: text("active_session_id"),
|
||||
|
|
@ -24,7 +24,7 @@ export const handoffRuntime = sqliteTable("handoff_runtime", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffSandboxes = sqliteTable("handoff_sandboxes", {
|
||||
export const taskSandboxes = sqliteTable("task_sandboxes", {
|
||||
sandboxId: text("sandbox_id").notNull().primaryKey(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
sandboxActorId: text("sandbox_actor_id"),
|
||||
|
|
@ -35,7 +35,7 @@ export const handoffSandboxes = sqliteTable("handoff_sandboxes", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffWorkbenchSessions = sqliteTable("handoff_workbench_sessions", {
|
||||
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
sessionName: text("session_name").notNull(),
|
||||
model: text("model").notNull(),
|
||||
400
factory/packages/backend/src/actors/task/index.ts
Normal file
400
factory/packages/backend/src/actors/task/index.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type {
|
||||
AgentType,
|
||||
TaskRecord,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
ProviderId
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { selfTask } from "../handles.js";
|
||||
import { taskDb } from "./db/db.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
getWorkbenchTask,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchTask,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
syncWorkbenchSessionStatus,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
updateWorkbenchDraft
|
||||
} from "./workbench.js";
|
||||
import {
|
||||
TASK_QUEUE_NAMES,
|
||||
taskWorkflowQueueName,
|
||||
runTaskWorkflow
|
||||
} from "./workflow/index.js";
|
||||
|
||||
export interface TaskInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoIds?: string[];
|
||||
taskId: string;
|
||||
repoRemote: string;
|
||||
repoLocalPath: string;
|
||||
branchName: string | null;
|
||||
title: string | null;
|
||||
task: string;
|
||||
providerId: ProviderId;
|
||||
agentType: AgentType | null;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
}
|
||||
|
||||
interface InitializeCommand {
|
||||
providerId?: ProviderId;
|
||||
}
|
||||
|
||||
interface TaskActionCommand {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface TaskTabCommand {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface TaskStatusSyncCommand {
|
||||
sessionId: string;
|
||||
status: "running" | "idle" | "error";
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchValueCommand {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionTitleCommand {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionUnreadCommand {
|
||||
sessionId: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchUpdateDraftCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchChangeModelCommand {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSendMessageCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const task = actor({
|
||||
db: taskDb,
|
||||
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000
|
||||
},
|
||||
createState: (_c, input: TaskInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoIds: input.repoIds?.length ? [...new Set(input.repoIds)] : [input.repoId],
|
||||
taskId: input.taskId,
|
||||
repoRemote: input.repoRemote,
|
||||
repoLocalPath: input.repoLocalPath,
|
||||
branchName: input.branchName,
|
||||
title: input.title,
|
||||
task: input.task,
|
||||
providerId: input.providerId,
|
||||
agentType: input.agentType,
|
||||
explicitTitle: input.explicitTitle,
|
||||
explicitBranchName: input.explicitBranchName,
|
||||
initialized: false,
|
||||
previousStatus: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
return expectQueueResponse<TaskRecord>(result);
|
||||
},
|
||||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30 * 60_000,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
});
|
||||
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
|
||||
},
|
||||
|
||||
async switch(c): Promise<{ switchTarget: string }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.switch"), {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
});
|
||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
||||
},
|
||||
|
||||
async push(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 180_000
|
||||
});
|
||||
},
|
||||
|
||||
async sync(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
});
|
||||
},
|
||||
|
||||
async merge(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
});
|
||||
},
|
||||
|
||||
async archive(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
void self
|
||||
.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
c.log.warn({
|
||||
msg: "archive command failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async kill(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000
|
||||
});
|
||||
},
|
||||
|
||||
async get(c): Promise<TaskRecord> {
|
||||
return await getCurrentRecord({ db: c.db, state: c.state });
|
||||
},
|
||||
|
||||
async getWorkbench(c) {
|
||||
return await getWorkbenchTask(c);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.mark_unread"), {}, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.rename_task"),
|
||||
{ value: input.value } satisfies TaskWorkbenchValueCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.rename_branch"),
|
||||
{ value: input.value } satisfies TaskWorkbenchValueCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.create_session"),
|
||||
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ tabId: string }>(result);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.rename_session"),
|
||||
{ sessionId: input.tabId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
|
||||
{ sessionId: input.tabId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.update_draft"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchUpdateDraftCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.change_model"),
|
||||
{ sessionId: input.tabId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.stop_session"),
|
||||
{ sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.sync_session_status"),
|
||||
input,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.close_session"),
|
||||
{ sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(c): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.publish_pr"), {}, {
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.revert_file"),
|
||||
input,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
run: workflow(runTaskWorkflow)
|
||||
});
|
||||
export { TASK_QUEUE_NAMES };
|
||||
|
|
@ -3,19 +3,19 @@ import { asc, eq } from "drizzle-orm";
|
|||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { repoLabelFromRemote } from "../../services/repo.js";
|
||||
import {
|
||||
getOrCreateHandoffStatusSync,
|
||||
getOrCreateProject,
|
||||
getOrCreateTaskStatusSync,
|
||||
getOrCreateRepo,
|
||||
getOrCreateWorkspace,
|
||||
getSandboxInstance,
|
||||
} from "../handles.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js";
|
||||
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
|
||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||
|
||||
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
|
||||
await c.db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS handoff_workbench_sessions (
|
||||
CREATE TABLE IF NOT EXISTS task_workbench_sessions (
|
||||
session_id text PRIMARY KEY NOT NULL,
|
||||
session_name text NOT NULL,
|
||||
model text NOT NULL,
|
||||
|
|
@ -77,8 +77,8 @@ async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }
|
|||
await ensureWorkbenchSessionTable(c);
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(handoffWorkbenchSessions)
|
||||
.orderBy(asc(handoffWorkbenchSessions.createdAt))
|
||||
.from(taskWorkbenchSessions)
|
||||
.orderBy(asc(taskWorkbenchSessions.createdAt))
|
||||
.all();
|
||||
const mapped = rows.map((row: any) => ({
|
||||
...row,
|
||||
|
|
@ -107,8 +107,8 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
|
|||
await ensureWorkbenchSessionTable(c);
|
||||
const row = await c.db
|
||||
.select()
|
||||
.from(handoffWorkbenchSessions)
|
||||
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
|
||||
.from(taskWorkbenchSessions)
|
||||
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
|
|
@ -145,7 +145,7 @@ async function ensureSessionMeta(c: any, params: {
|
|||
const unread = params.unread ?? false;
|
||||
|
||||
await c.db
|
||||
.insert(handoffWorkbenchSessions)
|
||||
.insert(taskWorkbenchSessions)
|
||||
.values({
|
||||
sessionId: params.sessionId,
|
||||
sessionName,
|
||||
|
|
@ -168,12 +168,12 @@ async function ensureSessionMeta(c: any, params: {
|
|||
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
await c.db
|
||||
.update(handoffWorkbenchSessions)
|
||||
.update(taskWorkbenchSessions)
|
||||
.set({
|
||||
...values,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
|
||||
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
|
||||
.run();
|
||||
return await readSessionMeta(c, sessionId);
|
||||
}
|
||||
|
|
@ -408,13 +408,13 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
|
|||
}
|
||||
|
||||
try {
|
||||
const project = await getOrCreateProject(
|
||||
const repo = await getOrCreateRepo(
|
||||
c,
|
||||
c.state.workspaceId,
|
||||
c.state.repoId,
|
||||
c.state.repoRemote,
|
||||
);
|
||||
return await project.getPullRequestForBranch({ branchName });
|
||||
return await repo.getPullRequestForBranch({ branchName });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -432,7 +432,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
|||
return record;
|
||||
}
|
||||
|
||||
export async function getWorkbenchHandoff(c: any): Promise<any> {
|
||||
export async function getWorkbenchTask(c: any): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await collectWorkbenchGitState(c, record);
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
|
|
@ -467,9 +467,10 @@ export async function getWorkbenchHandoff(c: any): Promise<any> {
|
|||
}
|
||||
|
||||
return {
|
||||
id: c.state.handoffId,
|
||||
id: c.state.taskId,
|
||||
repoId: c.state.repoId,
|
||||
title: record.title ?? "New Handoff",
|
||||
repoIds: c.state.repoIds?.length ? [...c.state.repoIds] : [c.state.repoId],
|
||||
title: record.title ?? "New Task",
|
||||
status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new",
|
||||
repoName: repoLabelFromRemote(c.state.repoRemote),
|
||||
updatedAtMs: record.updatedAt,
|
||||
|
|
@ -482,19 +483,19 @@ export async function getWorkbenchHandoff(c: any): Promise<any> {
|
|||
};
|
||||
}
|
||||
|
||||
export async function renameWorkbenchHandoff(c: any, value: string): Promise<void> {
|
||||
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
|
||||
const nextTitle = value.trim();
|
||||
if (!nextTitle) {
|
||||
throw new Error("handoff title is required");
|
||||
throw new Error("task title is required");
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({
|
||||
title: nextTitle,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
c.state.title = nextTitle;
|
||||
await notifyWorkbenchUpdated(c);
|
||||
|
|
@ -508,7 +509,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.branchName) {
|
||||
throw new Error("cannot rename branch before handoff branch exists");
|
||||
throw new Error("cannot rename branch before task branch exists");
|
||||
}
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot rename branch without an active sandbox");
|
||||
|
|
@ -535,18 +536,18 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
}
|
||||
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({
|
||||
branchName: nextBranch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
c.state.branchName = nextBranch;
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
await project.registerHandoffBranch({
|
||||
handoffId: c.state.handoffId,
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
await repo.registerTaskBranch({
|
||||
taskId: c.state.taskId,
|
||||
branchName: nextBranch,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
|
|
@ -650,25 +651,25 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
});
|
||||
|
||||
await c.db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSessionId: sessionId,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffRuntime.id, 1))
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
const sync = await getOrCreateTaskStatusSync(
|
||||
c,
|
||||
c.state.workspaceId,
|
||||
c.state.repoId,
|
||||
c.state.handoffId,
|
||||
c.state.taskId,
|
||||
record.activeSandboxId,
|
||||
sessionId,
|
||||
{
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: c.state.handoffId,
|
||||
taskId: c.state.taskId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId,
|
||||
|
|
@ -708,12 +709,12 @@ export async function syncWorkbenchSessionStatus(
|
|||
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
|
||||
if (record.status !== mappedStatus) {
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({
|
||||
status: mappedStatus,
|
||||
updatedAt: at,
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
changed = true;
|
||||
}
|
||||
|
|
@ -721,12 +722,12 @@ export async function syncWorkbenchSessionStatus(
|
|||
const statusMessage = `session:${status}`;
|
||||
if (record.statusMessage !== statusMessage) {
|
||||
await c.db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage,
|
||||
updatedAt: at,
|
||||
})
|
||||
.where(eq(handoffRuntime.id, 1))
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
changed = true;
|
||||
}
|
||||
|
|
@ -777,12 +778,12 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
|
|||
});
|
||||
if (record.activeSessionId === sessionId) {
|
||||
await c.db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSessionId: null,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffRuntime.id, 1))
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
|
|
@ -812,12 +813,12 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
|
|||
record.title ?? c.state.task,
|
||||
);
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({
|
||||
prSubmitted: 1,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffTable.id, 1))
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateHandoffStatusSync } from "../../handles.js";
|
||||
import { getOrCreateTaskStatusSync } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setHandoffState } from "./common.js";
|
||||
import { task as taskTable, taskRuntime } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
|
|
@ -36,7 +36,7 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
|
|||
sandboxId: record.activeSandboxId ?? ""
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.attach", {
|
||||
await appendHistory(loopCtx, "task.attach", {
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId
|
||||
});
|
||||
|
|
@ -50,9 +50,9 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
|
|||
export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
const runtime = await db
|
||||
.select({ switchTarget: handoffRuntime.activeSwitchTarget })
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.select({ switchTarget: taskRuntime.activeSwitchTarget })
|
||||
.from(taskRuntime)
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" });
|
||||
|
|
@ -61,7 +61,7 @@ export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void
|
|||
export async function handlePushActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: msg.body?.reason ?? null,
|
||||
historyKind: "handoff.push"
|
||||
historyKind: "task.push"
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
|
@ -74,9 +74,9 @@ export async function handleSimpleCommandActivity(
|
|||
): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage, updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
|
||||
|
|
@ -84,34 +84,34 @@ export async function handleSimpleCommandActivity(
|
|||
}
|
||||
|
||||
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "archive_stop_status_sync", "stopping status sync");
|
||||
await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
|
||||
if (record.activeSandboxId && record.activeSessionId) {
|
||||
try {
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
const sync = await getOrCreateTaskStatusSync(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.handoffId,
|
||||
loopCtx.state.taskId,
|
||||
record.activeSandboxId,
|
||||
record.activeSessionId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
providerId: record.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
intervalMs: 2_000
|
||||
}
|
||||
);
|
||||
await withTimeout(sync.stop(), 15_000, "handoff status sync stop");
|
||||
await withTimeout(sync.stop(), 15_000, "task status sync stop");
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.commands", "failed to stop status sync during archive", {
|
||||
logActorWarning("task.commands", "failed to stop status sync during archive", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
error: resolveErrorMessage(error)
|
||||
|
|
@ -120,14 +120,14 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
}
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const workspaceId = loopCtx.state.workspaceId;
|
||||
const repoId = loopCtx.state.repoId;
|
||||
const handoffId = loopCtx.state.handoffId;
|
||||
const taskId = loopCtx.state.taskId;
|
||||
const sandboxId = record.activeSandboxId;
|
||||
|
||||
// Do not block archive finalization on provider stop. Some provider stop calls can
|
||||
|
|
@ -140,10 +140,10 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
45_000,
|
||||
"provider releaseSandbox"
|
||||
).catch((error) => {
|
||||
logActorWarning("handoff.commands", "failed to release sandbox during archive", {
|
||||
logActorWarning("task.commands", "failed to release sandbox during archive", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
handoffId,
|
||||
taskId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
|
|
@ -151,25 +151,25 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
await setHandoffState(loopCtx, "archive_finalize", "finalizing archive");
|
||||
await setTaskState(loopCtx, "archive_finalize", "finalizing archive");
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({ status: "archived", updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.archive", { reason: msg.body?.reason ?? null });
|
||||
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
|
||||
await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
|
|
@ -186,21 +186,21 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
|||
}
|
||||
|
||||
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "kill_finalize", "finalizing kill");
|
||||
await setTaskState(loopCtx, "kill_finalize", "finalizing kill");
|
||||
const db = loopCtx.db;
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({ status: "killed", updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: "killed", updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null });
|
||||
await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
|
||||
import type { TaskRecord, TaskStatus } from "@sandbox-agent/factory-shared";
|
||||
import { getOrCreateWorkspace } from "../../handles.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { historyKey } from "../../keys.js";
|
||||
|
||||
export const HANDOFF_ROW_ID = 1;
|
||||
|
|
@ -58,22 +58,22 @@ export function buildAgentPrompt(task: string): string {
|
|||
return task.trim();
|
||||
}
|
||||
|
||||
export async function setHandoffState(
|
||||
export async function setTaskState(
|
||||
ctx: any,
|
||||
status: HandoffStatus,
|
||||
status: TaskStatus,
|
||||
statusMessage?: string
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const db = ctx.db;
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({ status, updatedAt: now })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
if (statusMessage != null) {
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
|
|
@ -84,7 +84,7 @@ export async function setHandoffState(
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
|
|
@ -97,50 +97,51 @@ export async function setHandoffState(
|
|||
await workspace.notifyWorkbenchUpdated({});
|
||||
}
|
||||
|
||||
export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
|
||||
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
||||
const db = ctx.db;
|
||||
const row = await db
|
||||
.select({
|
||||
branchName: handoffTable.branchName,
|
||||
title: handoffTable.title,
|
||||
task: handoffTable.task,
|
||||
providerId: handoffTable.providerId,
|
||||
status: handoffTable.status,
|
||||
statusMessage: handoffRuntime.statusMessage,
|
||||
activeSandboxId: handoffRuntime.activeSandboxId,
|
||||
activeSessionId: handoffRuntime.activeSessionId,
|
||||
agentType: handoffTable.agentType,
|
||||
prSubmitted: handoffTable.prSubmitted,
|
||||
createdAt: handoffTable.createdAt,
|
||||
updatedAt: handoffTable.updatedAt
|
||||
branchName: taskTable.branchName,
|
||||
title: taskTable.title,
|
||||
task: taskTable.task,
|
||||
providerId: taskTable.providerId,
|
||||
status: taskTable.status,
|
||||
statusMessage: taskRuntime.statusMessage,
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
activeSessionId: taskRuntime.activeSessionId,
|
||||
agentType: taskTable.agentType,
|
||||
prSubmitted: taskTable.prSubmitted,
|
||||
createdAt: taskTable.createdAt,
|
||||
updatedAt: taskTable.updatedAt
|
||||
})
|
||||
.from(handoffTable)
|
||||
.leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id))
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.from(taskTable)
|
||||
.leftJoin(taskRuntime, eq(taskTable.id, taskRuntime.id))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Handoff not found: ${ctx.state.handoffId}`);
|
||||
throw new Error(`Task not found: ${ctx.state.taskId}`);
|
||||
}
|
||||
|
||||
const sandboxes = await db
|
||||
.select({
|
||||
sandboxId: handoffSandboxes.sandboxId,
|
||||
providerId: handoffSandboxes.providerId,
|
||||
sandboxActorId: handoffSandboxes.sandboxActorId,
|
||||
switchTarget: handoffSandboxes.switchTarget,
|
||||
cwd: handoffSandboxes.cwd,
|
||||
createdAt: handoffSandboxes.createdAt,
|
||||
updatedAt: handoffSandboxes.updatedAt,
|
||||
sandboxId: taskSandboxes.sandboxId,
|
||||
providerId: taskSandboxes.providerId,
|
||||
sandboxActorId: taskSandboxes.sandboxActorId,
|
||||
switchTarget: taskSandboxes.switchTarget,
|
||||
cwd: taskSandboxes.cwd,
|
||||
createdAt: taskSandboxes.createdAt,
|
||||
updatedAt: taskSandboxes.updatedAt,
|
||||
})
|
||||
.from(handoffSandboxes)
|
||||
.from(taskSandboxes)
|
||||
.all();
|
||||
|
||||
return {
|
||||
workspaceId: ctx.state.workspaceId,
|
||||
repoId: ctx.state.repoId,
|
||||
repoIds: ctx.state.repoIds?.length ? [...ctx.state.repoIds] : [ctx.state.repoId],
|
||||
repoRemote: ctx.state.repoRemote,
|
||||
handoffId: ctx.state.handoffId,
|
||||
taskId: ctx.state.taskId,
|
||||
branchName: row.branchName,
|
||||
title: row.title,
|
||||
task: row.task,
|
||||
|
|
@ -171,7 +172,7 @@ export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
|
|||
reviewer: null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
} as HandoffRecord;
|
||||
} as TaskRecord;
|
||||
}
|
||||
|
||||
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
|
||||
|
|
@ -182,7 +183,7 @@ export async function appendHistory(ctx: any, kind: string, payload: Record<stri
|
|||
);
|
||||
await history.append({
|
||||
kind,
|
||||
handoffId: ctx.state.handoffId,
|
||||
taskId: ctx.state.taskId,
|
||||
branchName: ctx.state.branchName,
|
||||
payload
|
||||
});
|
||||
|
|
@ -26,7 +26,7 @@ import {
|
|||
killWriteDbActivity
|
||||
} from "./commands.js";
|
||||
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
|
||||
import { HANDOFF_QUEUE_NAMES } from "./queue.js";
|
||||
import { TASK_QUEUE_NAMES } from "./queue.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
|
|
@ -34,7 +34,7 @@ import {
|
|||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchHandoff,
|
||||
renameWorkbenchTask,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
|
|
@ -44,16 +44,16 @@ import {
|
|||
updateWorkbenchDraft,
|
||||
} from "../workbench.js";
|
||||
|
||||
export { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName } from "./queue.js";
|
||||
export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js";
|
||||
|
||||
const INIT_ENSURE_NAME_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
type HandoffQueueName = (typeof HANDOFF_QUEUE_NAMES)[number];
|
||||
type TaskQueueName = (typeof TASK_QUEUE_NAMES)[number];
|
||||
|
||||
type WorkflowHandler = (loopCtx: any, msg: { name: HandoffQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
|
||||
type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
|
||||
|
||||
const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
||||
"handoff.command.initialize": async (loopCtx, msg) => {
|
||||
const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||
"task.command.initialize": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
|
||||
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
|
||||
|
|
@ -67,13 +67,13 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
try {
|
||||
await msg.complete(currentRecord);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.workflow", "initialize completion failed", {
|
||||
logActorWarning("task.workflow", "initialize completion failed", {
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"handoff.command.provision": async (loopCtx, msg) => {
|
||||
"task.command.provision": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
await loopCtx.removed("init-failed", "step");
|
||||
try {
|
||||
|
|
@ -118,56 +118,56 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
}
|
||||
},
|
||||
|
||||
"handoff.command.attach": async (loopCtx, msg) => {
|
||||
"task.command.attach": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.switch": async (loopCtx, msg) => {
|
||||
"task.command.switch": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.push": async (loopCtx, msg) => {
|
||||
"task.command.push": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.sync": async (loopCtx, msg) => {
|
||||
"task.command.sync": async (loopCtx, msg) => {
|
||||
await loopCtx.step(
|
||||
"handle-sync",
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync")
|
||||
);
|
||||
},
|
||||
|
||||
"handoff.command.merge": async (loopCtx, msg) => {
|
||||
"task.command.merge": async (loopCtx, msg) => {
|
||||
await loopCtx.step(
|
||||
"handle-merge",
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge")
|
||||
);
|
||||
},
|
||||
|
||||
"handoff.command.archive": async (loopCtx, msg) => {
|
||||
"task.command.archive": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.kill": async (loopCtx, msg) => {
|
||||
"task.command.kill": async (loopCtx, msg) => {
|
||||
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
|
||||
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.get": async (loopCtx, msg) => {
|
||||
"task.command.get": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"handoff.command.workbench.mark_unread": async (loopCtx, msg) => {
|
||||
"task.command.workbench.mark_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.rename_handoff": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-handoff", async () => renameWorkbenchHandoff(loopCtx, msg.body.value));
|
||||
"task.command.workbench.rename_task": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.rename_branch": async (loopCtx, msg) => {
|
||||
"task.command.workbench.rename_branch": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-rename-branch",
|
||||
timeout: 5 * 60_000,
|
||||
|
|
@ -176,7 +176,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.create_session": async (loopCtx, msg) => {
|
||||
"task.command.workbench.create_session": async (loopCtx, msg) => {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session",
|
||||
timeout: 5 * 60_000,
|
||||
|
|
@ -185,35 +185,35 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete(created);
|
||||
},
|
||||
|
||||
"handoff.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
"task.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-session", async () =>
|
||||
renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.set_session_unread": async (loopCtx, msg) => {
|
||||
"task.command.workbench.set_session_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-set-session-unread", async () =>
|
||||
setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.update_draft": async (loopCtx, msg) => {
|
||||
"task.command.workbench.update_draft": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-update-draft", async () =>
|
||||
updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.change_model": async (loopCtx, msg) => {
|
||||
"task.command.workbench.change_model": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-change-model", async () =>
|
||||
changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
|
|
@ -222,7 +222,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-stop-session",
|
||||
timeout: 5 * 60_000,
|
||||
|
|
@ -231,14 +231,14 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.sync_session_status": async (loopCtx, msg) => {
|
||||
"task.command.workbench.sync_session_status": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-sync-session-status", async () =>
|
||||
syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.close_session": async (loopCtx, msg) => {
|
||||
"task.command.workbench.close_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-close-session",
|
||||
timeout: 5 * 60_000,
|
||||
|
|
@ -247,7 +247,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.publish_pr": async (loopCtx, msg) => {
|
||||
"task.command.workbench.publish_pr": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-publish-pr",
|
||||
timeout: 10 * 60_000,
|
||||
|
|
@ -256,7 +256,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.command.workbench.revert_file": async (loopCtx, msg) => {
|
||||
"task.command.workbench.revert_file": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-revert-file",
|
||||
timeout: 5 * 60_000,
|
||||
|
|
@ -265,7 +265,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"handoff.status_sync.result": async (loopCtx, msg) => {
|
||||
"task.status_sync.result": async (loopCtx, msg) => {
|
||||
const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body));
|
||||
|
||||
if (transitionedToIdle) {
|
||||
|
|
@ -278,16 +278,16 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
}
|
||||
};
|
||||
|
||||
export async function runHandoffWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("handoff-command-loop", async (loopCtx: any) => {
|
||||
export async function runTaskWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("task-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-command", {
|
||||
names: [...HANDOFF_QUEUE_NAMES],
|
||||
names: [...TASK_QUEUE_NAMES],
|
||||
completable: true
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
const handler = commandHandlers[msg.name as HandoffQueueName];
|
||||
const handler = commandHandlers[msg.name as TaskQueueName];
|
||||
if (handler) {
|
||||
await handler(loopCtx, msg);
|
||||
}
|
||||
|
|
@ -3,22 +3,22 @@ import { desc, eq } from "drizzle-orm";
|
|||
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import {
|
||||
getOrCreateHandoffStatusSync,
|
||||
getOrCreateTaskStatusSync,
|
||||
getOrCreateHistory,
|
||||
getOrCreateProject,
|
||||
getOrCreateRepo,
|
||||
getOrCreateSandboxInstance,
|
||||
selfHandoff
|
||||
selfTask
|
||||
} from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import {
|
||||
HANDOFF_ROW_ID,
|
||||
appendHistory,
|
||||
collectErrorMessages,
|
||||
resolveErrorDetail,
|
||||
setHandoffState
|
||||
setTaskState
|
||||
} from "./common.js";
|
||||
import { handoffWorkflowQueueName } from "./queue.js";
|
||||
import { taskWorkflowQueueName } from "./queue.js";
|
||||
|
||||
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
|
||||
|
||||
|
|
@ -37,10 +37,10 @@ function getInitCreateSandboxActivityTimeoutMs(): number {
|
|||
function debugInit(loopCtx: any, message: string, context?: Record<string, unknown>): void {
|
||||
loopCtx.log.debug({
|
||||
msg: message,
|
||||
scope: "handoff.init",
|
||||
scope: "task.init",
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
...(context ?? {})
|
||||
});
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
|
||||
try {
|
||||
await db
|
||||
.insert(handoffTable)
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
branchName: loopCtx.state.branchName,
|
||||
|
|
@ -89,7 +89,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffTable.id,
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
|
|
@ -103,7 +103,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
|
|
@ -114,7 +114,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
|
|
@ -127,36 +127,36 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
.run();
|
||||
} catch (error) {
|
||||
const detail = resolveErrorMessage(error);
|
||||
throw new Error(`handoff init bootstrap db failed: ${detail}`);
|
||||
throw new Error(`task init bootstrap db failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_enqueue_provision", "provision queued");
|
||||
const self = selfHandoff(loopCtx);
|
||||
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
|
||||
const self = selfTask(loopCtx);
|
||||
void self
|
||||
.send(handoffWorkflowQueueName("handoff.command.provision"), body, {
|
||||
.send(taskWorkflowQueueName("task.command.provision"), body, {
|
||||
wait: false,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logActorWarning("handoff.init", "background provision command failed", {
|
||||
logActorWarning("task.init", "background provision command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_ensure_name", "determining title and branch");
|
||||
await setTaskState(loopCtx, "init_ensure_name", "determining title and branch");
|
||||
const existing = await loopCtx.db
|
||||
.select({
|
||||
branchName: handoffTable.branchName,
|
||||
title: handoffTable.title
|
||||
branchName: taskTable.branchName,
|
||||
title: taskTable.title
|
||||
})
|
||||
.from(handoffTable)
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.from(taskTable)
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (existing?.branchName && existing?.title) {
|
||||
|
|
@ -169,10 +169,10 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.init", "fetch before naming failed", {
|
||||
logActorWarning("task.init", "fetch before naming failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
|
|
@ -180,31 +180,31 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
(branch: any) => branch.branchName
|
||||
);
|
||||
|
||||
const project = await getOrCreateProject(
|
||||
const repo = await getOrCreateRepo(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.repoRemote
|
||||
);
|
||||
const reservedBranches = await project.listReservedBranches({});
|
||||
const reservedBranches = await repo.listReservedBranches({});
|
||||
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
task: loopCtx.state.task,
|
||||
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
|
||||
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
|
||||
localBranches: remoteBranches,
|
||||
handoffBranches: reservedBranches
|
||||
taskBranches: reservedBranches
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({
|
||||
branchName: resolved.branchName,
|
||||
title: resolved.title,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
loopCtx.state.branchName = resolved.branchName;
|
||||
|
|
@ -213,34 +213,34 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
loopCtx.state.explicitBranchName = null;
|
||||
|
||||
await loopCtx.db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: "provisioning",
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await project.registerHandoffBranch({
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
await repo.registerTaskBranch({
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: resolved.branchName
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.named", {
|
||||
await appendHistory(loopCtx, "task.named", {
|
||||
title: resolved.title,
|
||||
branchName: resolved.branchName
|
||||
});
|
||||
}
|
||||
|
||||
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_assert_name", "validating naming");
|
||||
await setTaskState(loopCtx, "init_assert_name", "validating naming");
|
||||
if (!loopCtx.state.branchName) {
|
||||
throw new Error("handoff branchName is not initialized");
|
||||
throw new Error("task branchName is not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_create_sandbox", "creating sandbox");
|
||||
await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
|
|
@ -255,16 +255,16 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
|
||||
if (provider.capabilities().supportsSessionReuse) {
|
||||
const runtime = await loopCtx.db
|
||||
.select({ activeSandboxId: handoffRuntime.activeSandboxId })
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.select({ activeSandboxId: taskRuntime.activeSandboxId })
|
||||
.from(taskRuntime)
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
const existing = await loopCtx.db
|
||||
.select({ sandboxId: handoffSandboxes.sandboxId })
|
||||
.from(handoffSandboxes)
|
||||
.where(eq(handoffSandboxes.providerId, providerId))
|
||||
.orderBy(desc(handoffSandboxes.updatedAt))
|
||||
.select({ sandboxId: taskSandboxes.sandboxId })
|
||||
.from(taskSandboxes)
|
||||
.where(eq(taskSandboxes.providerId, providerId))
|
||||
.orderBy(desc(taskSandboxes.updatedAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
|
|
@ -287,10 +287,10 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
});
|
||||
return resumed;
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.init", "resume sandbox failed; creating a new sandbox", {
|
||||
logActorWarning("task.init", "resume sandbox failed; creating a new sandbox", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
|
|
@ -311,7 +311,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
repoId: loopCtx.state.repoId,
|
||||
repoRemote: loopCtx.state.repoRemote,
|
||||
branchName: loopCtx.state.branchName,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
debug: (message, context) => debugInit(loopCtx, message, context)
|
||||
})
|
||||
);
|
||||
|
|
@ -331,7 +331,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
}
|
||||
|
||||
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
|
||||
await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
|
|
@ -347,7 +347,7 @@ export async function initStartSandboxInstanceActivity(
|
|||
sandbox: any,
|
||||
agent: any
|
||||
): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
try {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(
|
||||
|
|
@ -392,7 +392,7 @@ export async function initCreateSessionActivity(
|
|||
sandbox: any,
|
||||
sandboxInstanceReady: any
|
||||
): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_create_session", "deferring agent session creation");
|
||||
await setTaskState(loopCtx, "init_create_session", "deferring agent session creation");
|
||||
if (!sandboxInstanceReady.ok) {
|
||||
return {
|
||||
id: null,
|
||||
|
|
@ -415,7 +415,7 @@ export async function initWriteDbActivity(
|
|||
session: any,
|
||||
sandboxInstanceReady?: { actorId?: string | null }
|
||||
): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime");
|
||||
await setTaskState(loopCtx, "init_write_db", "persisting task runtime");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
|
|
@ -443,18 +443,18 @@ export async function initWriteDbActivity(
|
|||
: null;
|
||||
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({
|
||||
providerId,
|
||||
status: sessionHealthy ? "idle" : "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffSandboxes)
|
||||
.insert(taskSandboxes)
|
||||
.values({
|
||||
sandboxId: sandbox.sandboxId,
|
||||
providerId,
|
||||
|
|
@ -466,7 +466,7 @@ export async function initWriteDbActivity(
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffSandboxes.sandboxId,
|
||||
target: taskSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
|
|
@ -479,7 +479,7 @@ export async function initWriteDbActivity(
|
|||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
|
|
@ -490,7 +490,7 @@ export async function initWriteDbActivity(
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
|
|
@ -514,19 +514,19 @@ export async function initStartStatusSyncActivity(
|
|||
return;
|
||||
}
|
||||
|
||||
await setHandoffState(loopCtx, "init_start_status_sync", "starting session status sync");
|
||||
await setTaskState(loopCtx, "init_start_status_sync", "starting session status sync");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
const sync = await getOrCreateTaskStatusSync(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.handoffId,
|
||||
loopCtx.state.taskId,
|
||||
sandbox.sandboxId,
|
||||
sessionId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
sessionId,
|
||||
|
|
@ -543,12 +543,12 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
|
|||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = !sessionId || session?.status !== "error";
|
||||
if (sessionHealthy) {
|
||||
await setHandoffState(loopCtx, "init_complete", "handoff initialized");
|
||||
await setTaskState(loopCtx, "init_complete", "task initialized");
|
||||
|
||||
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
|
||||
await history.append({
|
||||
kind: "handoff.initialized",
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
kind: "task.initialized",
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId: sessionId ?? null }
|
||||
});
|
||||
|
|
@ -561,8 +561,8 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
|
|||
session?.status === "error"
|
||||
? (session.error ?? "session create failed")
|
||||
: "session unavailable";
|
||||
await setHandoffState(loopCtx, "error", detail);
|
||||
await appendHistory(loopCtx, "handoff.error", {
|
||||
await setTaskState(loopCtx, "error", detail);
|
||||
await appendHistory(loopCtx, "task.error", {
|
||||
detail,
|
||||
messages: [detail]
|
||||
});
|
||||
|
|
@ -578,7 +578,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
const providerId = loopCtx.state.providerId ?? providers.defaultProviderId();
|
||||
|
||||
await db
|
||||
.insert(handoffTable)
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
|
|
@ -591,7 +591,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffTable.id,
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
|
|
@ -605,7 +605,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
.run();
|
||||
|
||||
await db
|
||||
.insert(handoffRuntime)
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: HANDOFF_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
|
|
@ -616,7 +616,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
|
|
@ -628,7 +628,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
})
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.error", {
|
||||
await appendHistory(loopCtx, "task.error", {
|
||||
detail,
|
||||
messages
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import { taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
|
||||
|
||||
export interface PushActiveBranchOptions {
|
||||
|
|
@ -21,7 +21,7 @@ export async function pushActiveBranchActivity(
|
|||
throw new Error("cannot push: no active sandbox");
|
||||
}
|
||||
if (!branchName) {
|
||||
throw new Error("cannot push: handoff branch is not set");
|
||||
throw new Error("cannot push: task branch is not set");
|
||||
}
|
||||
|
||||
const activeSandbox =
|
||||
|
|
@ -37,15 +37,15 @@ export async function pushActiveBranchActivity(
|
|||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(handoffSandboxes)
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(handoffSandboxes.sandboxId, activeSandboxId))
|
||||
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
const script = [
|
||||
|
|
@ -69,18 +69,18 @@ export async function pushActiveBranchActivity(
|
|||
|
||||
const updatedAt = Date.now();
|
||||
await loopCtx.db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(handoffSandboxes)
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(handoffSandboxes.sandboxId, activeSandboxId))
|
||||
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, options.historyKind ?? "handoff.push", {
|
||||
await appendHistory(loopCtx, options.historyKind ?? "task.push", {
|
||||
reason: options.reason ?? null,
|
||||
branchName,
|
||||
sandboxId: activeSandboxId
|
||||
31
factory/packages/backend/src/actors/task/workflow/queue.ts
Normal file
31
factory/packages/backend/src/actors/task/workflow/queue.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export const TASK_QUEUE_NAMES = [
|
||||
"task.command.initialize",
|
||||
"task.command.provision",
|
||||
"task.command.attach",
|
||||
"task.command.switch",
|
||||
"task.command.push",
|
||||
"task.command.sync",
|
||||
"task.command.merge",
|
||||
"task.command.archive",
|
||||
"task.command.kill",
|
||||
"task.command.get",
|
||||
"task.command.workbench.mark_unread",
|
||||
"task.command.workbench.rename_task",
|
||||
"task.command.workbench.rename_branch",
|
||||
"task.command.workbench.create_session",
|
||||
"task.command.workbench.rename_session",
|
||||
"task.command.workbench.set_session_unread",
|
||||
"task.command.workbench.update_draft",
|
||||
"task.command.workbench.change_model",
|
||||
"task.command.workbench.send_message",
|
||||
"task.command.workbench.stop_session",
|
||||
"task.command.workbench.sync_session_status",
|
||||
"task.command.workbench.close_session",
|
||||
"task.command.workbench.publish_pr",
|
||||
"task.command.workbench.revert_file",
|
||||
"task.status_sync.result"
|
||||
] as const;
|
||||
|
||||
export function taskWorkflowQueueName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
|
|
@ -25,11 +25,11 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
|
|||
const db = loopCtx.db;
|
||||
const runtime = await db
|
||||
.select({
|
||||
activeSandboxId: handoffRuntime.activeSandboxId,
|
||||
activeSessionId: handoffRuntime.activeSessionId
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
activeSessionId: taskRuntime.activeSessionId
|
||||
})
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.from(taskRuntime)
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
const isActive =
|
||||
|
|
@ -37,25 +37,25 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
|
|||
|
||||
if (isActive) {
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({ status: newStatus, updatedAt: body.at })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(handoffSandboxes)
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(handoffSandboxes.sandboxId, body.sandboxId))
|
||||
.where(eq(taskSandboxes.sandboxId, body.sandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.status", {
|
||||
await appendHistory(loopCtx, "task.status", {
|
||||
status: body.status,
|
||||
sessionId: body.sessionId,
|
||||
sandboxId: body.sandboxId
|
||||
|
|
@ -79,9 +79,9 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
const db = loopCtx.db;
|
||||
|
||||
const self = await db
|
||||
.select({ prSubmitted: handoffTable.prSubmitted })
|
||||
.from(handoffTable)
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.select({ prSubmitted: taskTable.prSubmitted })
|
||||
.from(taskTable)
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (self && self.prSubmitted) return;
|
||||
|
|
@ -89,22 +89,22 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.status-sync", "fetch before PR submit failed", {
|
||||
logActorWarning("task.status-sync", "fetch before PR submit failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
|
||||
if (!loopCtx.state.branchName || !loopCtx.state.title) {
|
||||
throw new Error("cannot submit PR before handoff has a branch and title");
|
||||
throw new Error("cannot submit PR before task has a branch and title");
|
||||
}
|
||||
|
||||
try {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: "auto_submit_idle",
|
||||
historyKind: "handoff.push.auto"
|
||||
historyKind: "task.push.auto"
|
||||
});
|
||||
|
||||
const pr = await driver.github.createPr(
|
||||
|
|
@ -114,21 +114,21 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
);
|
||||
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.update(taskTable)
|
||||
.set({ prSubmitted: 1, updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.step", {
|
||||
await appendHistory(loopCtx, "task.step", {
|
||||
step: "pr_submit",
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.pr_created", {
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
await appendHistory(loopCtx, "task.pr_created", {
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number
|
||||
|
|
@ -136,16 +136,16 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
} catch (error) {
|
||||
const detail = resolveErrorDetail(error);
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: `pr submit failed: ${detail}`,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.pr_create_failed", {
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
await appendHistory(loopCtx, "task.pr_create_failed", {
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
error: detail
|
||||
});
|
||||
|
|
@ -3,38 +3,43 @@ import { desc, eq } from "drizzle-orm";
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import type {
|
||||
AddRepoInput,
|
||||
CreateHandoffInput,
|
||||
HandoffRecord,
|
||||
HandoffSummary,
|
||||
HandoffWorkbenchChangeModelInput,
|
||||
HandoffWorkbenchCreateHandoffInput,
|
||||
HandoffWorkbenchDiffInput,
|
||||
HandoffWorkbenchRenameInput,
|
||||
HandoffWorkbenchRenameSessionInput,
|
||||
HandoffWorkbenchSelectInput,
|
||||
HandoffWorkbenchSetSessionUnreadInput,
|
||||
HandoffWorkbenchSendMessageInput,
|
||||
HandoffWorkbenchSnapshot,
|
||||
HandoffWorkbenchTabInput,
|
||||
HandoffWorkbenchUpdateDraftInput,
|
||||
CreateTaskInput,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchCreateTaskInput,
|
||||
TaskWorkbenchDiffInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSelectInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
HistoryEvent,
|
||||
HistoryQueryInput,
|
||||
ListHandoffsInput,
|
||||
ListTasksInput,
|
||||
ProviderId,
|
||||
RepoOverview,
|
||||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
RepoRecord,
|
||||
SwitchResult,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkbenchCreateTaskInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
WorkspaceUseInput
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { getTask, getOrCreateHistory, getOrCreateRepo, selfWorkspace } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { normalizeRemoteUrl, repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||
import { handoffLookup, repos, providerProfiles } from "./db/schema.js";
|
||||
import { agentTypeForModel } from "../handoff/workbench.js";
|
||||
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
|
||||
import { agentTypeForModel } from "../task/workbench.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { workspaceAppActions } from "./app-shell.js";
|
||||
|
||||
interface WorkspaceState {
|
||||
workspaceId: string;
|
||||
|
|
@ -44,12 +49,12 @@ interface RefreshProviderProfilesCommand {
|
|||
providerId?: ProviderId;
|
||||
}
|
||||
|
||||
interface GetHandoffInput {
|
||||
interface GetTaskInput {
|
||||
workspaceId: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface HandoffProxyActionInput extends GetHandoffInput {
|
||||
interface TaskProxyActionInput extends GetTaskInput {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +65,7 @@ interface RepoOverviewInput {
|
|||
|
||||
const WORKSPACE_QUEUE_NAMES = [
|
||||
"workspace.command.addRepo",
|
||||
"workspace.command.createHandoff",
|
||||
"workspace.command.createTask",
|
||||
"workspace.command.refreshProviderProfiles",
|
||||
] as const;
|
||||
|
||||
|
|
@ -78,49 +83,54 @@ function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): voi
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveRepoId(c: any, handoffId: string): Promise<string> {
|
||||
async function resolveRepoId(c: any, taskId: string): Promise<string> {
|
||||
const row = await c.db
|
||||
.select({ repoId: handoffLookup.repoId })
|
||||
.from(handoffLookup)
|
||||
.where(eq(handoffLookup.handoffId, handoffId))
|
||||
.select({ repoId: taskLookup.repoId })
|
||||
.from(taskLookup)
|
||||
.where(eq(taskLookup.taskId, taskId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Unknown handoff: ${handoffId} (not in lookup)`);
|
||||
throw new Error(`Unknown task: ${taskId} (not in lookup)`);
|
||||
}
|
||||
|
||||
return row.repoId;
|
||||
}
|
||||
|
||||
async function upsertHandoffLookupRow(c: any, handoffId: string, repoId: string): Promise<void> {
|
||||
async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise<void> {
|
||||
await c.db
|
||||
.insert(handoffLookup)
|
||||
.insert(taskLookup)
|
||||
.values({
|
||||
handoffId,
|
||||
taskId,
|
||||
repoId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffLookup.handoffId,
|
||||
target: taskLookup.taskId,
|
||||
set: { repoId },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
|
||||
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
|
||||
const repoRows = await c.db
|
||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.orderBy(desc(repos.updatedAt))
|
||||
.all();
|
||||
|
||||
const all: HandoffSummary[] = [];
|
||||
const all = new Map<string, TaskSummary>();
|
||||
for (const row of repoRows) {
|
||||
try {
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
|
||||
const snapshot = await project.listHandoffSummaries({ includeArchived: true });
|
||||
all.push(...snapshot);
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, row.repoId, row.remoteUrl);
|
||||
const snapshot = await repo.listTaskSummaries({ includeArchived: true });
|
||||
for (const summary of snapshot) {
|
||||
const existing = all.get(summary.taskId);
|
||||
if (!existing || summary.updatedAt > existing.updatedAt) {
|
||||
all.set(summary.taskId, summary);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed collecting handoffs for repo", {
|
||||
logActorWarning("workspace", "failed collecting tasks for repo", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
error: resolveErrorMessage(error)
|
||||
|
|
@ -128,49 +138,41 @@ async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
|
|||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return all;
|
||||
return [...all.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot> {
|
||||
async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
|
||||
const repoRows = await c.db
|
||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
|
||||
.from(repos)
|
||||
.orderBy(desc(repos.updatedAt))
|
||||
.all();
|
||||
|
||||
const handoffs: Array<any> = [];
|
||||
const projects: Array<any> = [];
|
||||
const tasksById = new Map<string, any>();
|
||||
const repoSections: Array<any> = [];
|
||||
for (const row of repoRows) {
|
||||
const projectHandoffs: Array<any> = [];
|
||||
const repoTasks: Array<any> = [];
|
||||
try {
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
|
||||
const summaries = await project.listHandoffSummaries({ includeArchived: true });
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, row.repoId, row.remoteUrl);
|
||||
const summaries = await repo.listTaskSummaries({ includeArchived: true });
|
||||
for (const summary of summaries) {
|
||||
try {
|
||||
await upsertHandoffLookupRow(c, summary.handoffId, row.repoId);
|
||||
const handoff = getHandoff(c, c.state.workspaceId, row.repoId, summary.handoffId);
|
||||
const snapshot = await handoff.getWorkbench({});
|
||||
handoffs.push(snapshot);
|
||||
projectHandoffs.push(snapshot);
|
||||
const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId);
|
||||
const snapshot = await task.getWorkbench({});
|
||||
if (!tasksById.has(snapshot.id)) {
|
||||
tasksById.set(snapshot.id, snapshot);
|
||||
}
|
||||
repoTasks.push(snapshot);
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed collecting workbench handoff", {
|
||||
logActorWarning("workspace", "failed collecting workbench task", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
handoffId: summary.handoffId,
|
||||
taskId: summary.taskId,
|
||||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (projectHandoffs.length > 0) {
|
||||
projects.push({
|
||||
id: row.repoId,
|
||||
label: repoLabelFromRemote(row.remoteUrl),
|
||||
updatedAtMs: projectHandoffs[0]?.updatedAtMs ?? row.updatedAt,
|
||||
handoffs: projectHandoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed collecting workbench repo snapshot", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
|
|
@ -178,24 +180,33 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
|
|||
error: resolveErrorMessage(error)
|
||||
});
|
||||
}
|
||||
|
||||
const sortedRepoTasks = repoTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
repoSections.push({
|
||||
id: row.repoId,
|
||||
label: repoLabelFromRemote(row.remoteUrl),
|
||||
updatedAtMs: sortedRepoTasks[0]?.updatedAtMs ?? row.updatedAt,
|
||||
tasks: sortedRepoTasks,
|
||||
});
|
||||
}
|
||||
|
||||
handoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
const tasks = [...tasksById.values()].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
repoSections.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => ({
|
||||
id: row.repoId,
|
||||
label: repoLabelFromRemote(row.remoteUrl)
|
||||
})),
|
||||
projects,
|
||||
handoffs,
|
||||
repoSections,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireWorkbenchHandoff(c: any, handoffId: string) {
|
||||
const repoId = await resolveRepoId(c, handoffId);
|
||||
return getHandoff(c, c.state.workspaceId, repoId, handoffId);
|
||||
async function requireWorkbenchTask(c: any, taskId: string) {
|
||||
const repoId = await resolveRepoId(c, taskId);
|
||||
void repoId;
|
||||
return getTask(c, c.state.workspaceId, taskId);
|
||||
}
|
||||
|
||||
async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord> {
|
||||
|
|
@ -239,7 +250,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
};
|
||||
}
|
||||
|
||||
async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise<HandoffRecord> {
|
||||
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
|
|
@ -272,10 +283,11 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
|
|||
})
|
||||
.run();
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
|
||||
await project.ensure({ remoteUrl });
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, repoId, remoteUrl);
|
||||
await repo.ensure({ remoteUrl });
|
||||
|
||||
const created = await project.createHandoff({
|
||||
const created = await repo.createTask({
|
||||
repoIds: input.repoIds,
|
||||
task: input.task,
|
||||
providerId,
|
||||
agentType: input.agentType ?? null,
|
||||
|
|
@ -285,19 +297,41 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
|
|||
});
|
||||
|
||||
await c.db
|
||||
.insert(handoffLookup)
|
||||
.insert(taskLookup)
|
||||
.values({
|
||||
handoffId: created.handoffId,
|
||||
taskId: created.taskId,
|
||||
repoId
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffLookup.handoffId,
|
||||
target: taskLookup.taskId,
|
||||
set: { repoId }
|
||||
})
|
||||
.run();
|
||||
|
||||
const handoff = getHandoff(c, c.state.workspaceId, repoId, created.handoffId);
|
||||
await handoff.provision({ providerId });
|
||||
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
|
||||
await task.provision({ providerId });
|
||||
|
||||
for (const linkedRepoId of input.repoIds ?? []) {
|
||||
if (linkedRepoId === repoId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedRepoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.where(eq(repos.repoId, linkedRepoId))
|
||||
.get();
|
||||
if (!linkedRepoRow) {
|
||||
throw new Error(`Unknown linked repo: ${linkedRepoId}`);
|
||||
}
|
||||
|
||||
const linkedRepo = await getOrCreateRepo(c, c.state.workspaceId, linkedRepoId, linkedRepoRow.remoteUrl);
|
||||
await linkedRepo.ensure({ remoteUrl: linkedRepoRow.remoteUrl });
|
||||
await linkedRepo.linkTask({
|
||||
taskId: created.taskId,
|
||||
branchName: null,
|
||||
});
|
||||
}
|
||||
|
||||
await workspaceActions.notifyWorkbenchUpdated(c);
|
||||
return created;
|
||||
|
|
@ -347,11 +381,11 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.createHandoff") {
|
||||
if (msg.name === "workspace.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-create-handoff",
|
||||
name: "workspace-create-task",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffInput),
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
|
|
@ -369,6 +403,8 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
|||
}
|
||||
|
||||
export const workspaceActions = {
|
||||
...workspaceAppActions,
|
||||
|
||||
async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return { workspaceId: c.state.workspaceId };
|
||||
|
|
@ -407,17 +443,17 @@ export const workspaceActions = {
|
|||
}));
|
||||
},
|
||||
|
||||
async createHandoff(c: any, input: CreateHandoffInput): Promise<HandoffRecord> {
|
||||
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
|
||||
const self = selfWorkspace(c);
|
||||
return expectQueueResponse<HandoffRecord>(
|
||||
await self.send(workspaceWorkflowQueueName("workspace.command.createHandoff"), input, {
|
||||
return expectQueueResponse<TaskRecord>(
|
||||
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
|
||||
wait: true,
|
||||
timeout: 12 * 60_000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<HandoffWorkbenchSnapshot> {
|
||||
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<TaskWorkbenchSnapshot> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return await buildWorkbenchSnapshot(c);
|
||||
},
|
||||
|
|
@ -426,84 +462,85 @@ export const workspaceActions = {
|
|||
c.broadcast("workbenchUpdated", { at: Date.now() });
|
||||
},
|
||||
|
||||
async createWorkbenchHandoff(c: any, input: HandoffWorkbenchCreateHandoffInput): Promise<{ handoffId: string }> {
|
||||
const created = await workspaceActions.createHandoff(c, {
|
||||
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> {
|
||||
const created = await workspaceActions.createTask(c, {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: input.repoId,
|
||||
...(input.repoIds?.length ? { repoIds: input.repoIds } : {}),
|
||||
task: input.task,
|
||||
...(input.title ? { explicitTitle: input.title } : {}),
|
||||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {})
|
||||
});
|
||||
return { handoffId: created.handoffId };
|
||||
return { taskId: created.taskId };
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c: any, input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.markWorkbenchUnread({});
|
||||
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.markWorkbenchUnread({});
|
||||
},
|
||||
|
||||
async renameWorkbenchHandoff(c: any, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.renameWorkbenchHandoff(input);
|
||||
async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.renameWorkbenchTask(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(c: any, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.renameWorkbenchBranch(input);
|
||||
async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.renameWorkbenchBranch(input);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(c: any, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
return await handoff.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
|
||||
async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(c: any, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.renameWorkbenchSession(input);
|
||||
async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.renameWorkbenchSession(input);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(c: any, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.setWorkbenchSessionUnread(input);
|
||||
async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.setWorkbenchSessionUnread(input);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(c: any, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.updateWorkbenchDraft(input);
|
||||
async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.updateWorkbenchDraft(input);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(c: any, input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.changeWorkbenchModel(input);
|
||||
async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.changeWorkbenchModel(input);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c: any, input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.sendWorkbenchMessage(input);
|
||||
async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.sendWorkbenchMessage(input);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.stopWorkbenchSession(input);
|
||||
async stopWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.stopWorkbenchSession(input);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.closeWorkbenchSession(input);
|
||||
async closeWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.closeWorkbenchSession(input);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(c: any, input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.publishWorkbenchPr({});
|
||||
async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.publishWorkbenchPr({});
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(c: any, input: HandoffWorkbenchDiffInput): Promise<void> {
|
||||
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
|
||||
await handoff.revertWorkbenchFile(input);
|
||||
async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise<void> {
|
||||
const task = await requireWorkbenchTask(c, input.taskId);
|
||||
await task.revertWorkbenchFile(input);
|
||||
},
|
||||
|
||||
async listHandoffs(c: any, input: ListHandoffsInput): Promise<HandoffSummary[]> {
|
||||
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
if (input.repoId) {
|
||||
|
|
@ -516,11 +553,11 @@ export const workspaceActions = {
|
|||
throw new Error(`Unknown repo: ${input.repoId}`);
|
||||
}
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
|
||||
return await project.listHandoffSummaries({ includeArchived: true });
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
|
||||
return await repo.listTaskSummaries({ includeArchived: true });
|
||||
}
|
||||
|
||||
return await collectAllHandoffSummaries(c);
|
||||
return await collectAllTaskSummaries(c);
|
||||
},
|
||||
|
||||
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
|
||||
|
|
@ -535,9 +572,9 @@ export const workspaceActions = {
|
|||
throw new Error(`Unknown repo: ${input.repoId}`);
|
||||
}
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
|
||||
await project.ensure({ remoteUrl: repoRow.remoteUrl });
|
||||
return await project.getRepoOverview({});
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
|
||||
await repo.ensure({ remoteUrl: repoRow.remoteUrl });
|
||||
return await repo.getRepoOverview({});
|
||||
},
|
||||
|
||||
async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> {
|
||||
|
|
@ -552,24 +589,25 @@ export const workspaceActions = {
|
|||
throw new Error(`Unknown repo: ${input.repoId}`);
|
||||
}
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
|
||||
await project.ensure({ remoteUrl: repoRow.remoteUrl });
|
||||
return await project.runRepoStackAction({
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
|
||||
await repo.ensure({ remoteUrl: repoRow.remoteUrl });
|
||||
return await repo.runRepoStackAction({
|
||||
action: input.action,
|
||||
branchName: input.branchName,
|
||||
parentBranch: input.parentBranch
|
||||
});
|
||||
},
|
||||
|
||||
async switchHandoff(c: any, handoffId: string): Promise<SwitchResult> {
|
||||
const repoId = await resolveRepoId(c, handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, handoffId);
|
||||
async switchTask(c: any, taskId: string): Promise<SwitchResult> {
|
||||
const repoId = await resolveRepoId(c, taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, taskId);
|
||||
const record = await h.get();
|
||||
const switched = await h.switch();
|
||||
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
handoffId,
|
||||
taskId,
|
||||
providerId: record.providerId,
|
||||
switchTarget: switched.switchTarget
|
||||
};
|
||||
|
|
@ -596,7 +634,7 @@ export const workspaceActions = {
|
|||
const hist = await getOrCreateHistory(c, c.state.workspaceId, row.repoId);
|
||||
const items = await hist.list({
|
||||
branch: input.branch,
|
||||
handoffId: input.handoffId,
|
||||
taskId: input.taskId,
|
||||
limit
|
||||
});
|
||||
allEvents.push(...items);
|
||||
|
|
@ -613,10 +651,10 @@ export const workspaceActions = {
|
|||
return allEvents.slice(0, limit);
|
||||
},
|
||||
|
||||
async getHandoff(c: any, input: GetHandoffInput): Promise<HandoffRecord> {
|
||||
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
|
||||
const repoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
|
|
@ -627,49 +665,55 @@ export const workspaceActions = {
|
|||
throw new Error(`Unknown repo: ${repoId}`);
|
||||
}
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, repoRow.remoteUrl);
|
||||
return await project.getHandoffEnriched({ handoffId: input.handoffId });
|
||||
const repo = await getOrCreateRepo(c, c.state.workspaceId, repoId, repoRow.remoteUrl);
|
||||
return await repo.getTaskEnriched({ taskId: input.taskId });
|
||||
},
|
||||
|
||||
async attachHandoff(c: any, input: HandoffProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
|
||||
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, input.taskId);
|
||||
return await h.attach({ reason: input.reason });
|
||||
},
|
||||
|
||||
async pushHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
|
||||
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, input.taskId);
|
||||
await h.push({ reason: input.reason });
|
||||
},
|
||||
|
||||
async syncHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
|
||||
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, input.taskId);
|
||||
await h.sync({ reason: input.reason });
|
||||
},
|
||||
|
||||
async mergeHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
|
||||
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, input.taskId);
|
||||
await h.merge({ reason: input.reason });
|
||||
},
|
||||
|
||||
async archiveHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
|
||||
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, input.taskId);
|
||||
await h.archive({ reason: input.reason });
|
||||
},
|
||||
|
||||
async killHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
|
||||
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
const repoId = await resolveRepoId(c, input.taskId);
|
||||
void repoId;
|
||||
const h = getTask(c, c.state.workspaceId, input.taskId);
|
||||
await h.kill({ reason: input.reason });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
1472
factory/packages/backend/src/actors/workspace/app-shell.ts
Normal file
1472
factory/packages/backend/src/actors/workspace/app-shell.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
CREATE TABLE `handoff_lookup` (
|
||||
CREATE TABLE `task_lookup` (
|
||||
`handoff_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
ALTER TABLE `organization_profile` ADD COLUMN `github_sync_status` text NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE `organization_profile` ADD COLUMN `github_last_sync_at` integer;
|
||||
UPDATE `organization_profile`
|
||||
SET `github_sync_status` = CASE
|
||||
WHEN `repo_import_status` = 'ready' THEN 'synced'
|
||||
WHEN `repo_import_status` = 'importing' THEN 'syncing'
|
||||
ELSE 'pending'
|
||||
END;
|
||||
|
|
@ -21,6 +21,48 @@ const journal = {
|
|||
"when": 1772668800000,
|
||||
"tag": "0002_tiny_silver_surfer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"when": 1773100800000,
|
||||
"tag": "0003_app_shell_organization_profile",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"when": 1773100800001,
|
||||
"tag": "0004_app_shell_organization_members",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"when": 1773100800002,
|
||||
"tag": "0005_app_shell_seat_assignments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"when": 1773100800003,
|
||||
"tag": "0006_app_shell_invoices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"when": 1773100800004,
|
||||
"tag": "0007_app_shell_sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"when": 1773100800005,
|
||||
"tag": "0008_app_shell_stripe_lookup",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"when": 1773100800006,
|
||||
"tag": "0009_github_sync_status",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
|
|
@ -41,10 +83,94 @@ export default {
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0002: `CREATE TABLE \`handoff_lookup\` (
|
||||
\`handoff_id\` text PRIMARY KEY NOT NULL,
|
||||
m0002: `CREATE TABLE \`task_lookup\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL
|
||||
);
|
||||
`,
|
||||
m0003: `CREATE TABLE \`organization_profile\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`kind\` text NOT NULL,
|
||||
\`github_account_id\` text NOT NULL,
|
||||
\`github_login\` text NOT NULL,
|
||||
\`github_account_type\` text NOT NULL,
|
||||
\`display_name\` text NOT NULL,
|
||||
\`slug\` text NOT NULL,
|
||||
\`primary_domain\` text NOT NULL,
|
||||
\`default_model\` text NOT NULL,
|
||||
\`auto_import_repos\` integer NOT NULL,
|
||||
\`repo_import_status\` text NOT NULL,
|
||||
\`github_connected_account\` text NOT NULL,
|
||||
\`github_installation_status\` text NOT NULL,
|
||||
\`github_installation_id\` integer,
|
||||
\`github_last_sync_label\` text NOT NULL,
|
||||
\`stripe_customer_id\` text,
|
||||
\`stripe_subscription_id\` text,
|
||||
\`stripe_price_id\` text,
|
||||
\`billing_plan_id\` text NOT NULL,
|
||||
\`billing_status\` text NOT NULL,
|
||||
\`billing_seats_included\` integer NOT NULL,
|
||||
\`billing_trial_ends_at\` text,
|
||||
\`billing_renewal_at\` text,
|
||||
\`billing_payment_method_label\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0004: `CREATE TABLE \`organization_members\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`role\` text NOT NULL,
|
||||
\`state\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0005: `CREATE TABLE \`seat_assignments\` (
|
||||
\`email\` text PRIMARY KEY NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0006: `CREATE TABLE \`invoices\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`label\` text NOT NULL,
|
||||
\`issued_at\` text NOT NULL,
|
||||
\`amount_usd\` integer NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0007: `CREATE TABLE \`app_sessions\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`current_user_id\` text,
|
||||
\`current_user_name\` text,
|
||||
\`current_user_email\` text,
|
||||
\`current_user_github_login\` text,
|
||||
\`current_user_role_label\` text,
|
||||
\`eligible_organization_ids_json\` text NOT NULL,
|
||||
\`active_organization_id\` text,
|
||||
\`github_access_token\` text,
|
||||
\`github_scope\` text NOT NULL,
|
||||
\`oauth_state\` text,
|
||||
\`oauth_state_expires_at\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0008: `CREATE TABLE \`stripe_lookup\` (
|
||||
\`lookup_key\` text PRIMARY KEY NOT NULL,
|
||||
\`organization_id\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0009: `ALTER TABLE \`organization_profile\` ADD COLUMN \`github_sync_status\` text NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE \`organization_profile\` ADD COLUMN \`github_last_sync_at\` integer;
|
||||
UPDATE \`organization_profile\`
|
||||
SET \`github_sync_status\` = CASE
|
||||
WHEN \`repo_import_status\` = 'ready' THEN 'synced'
|
||||
WHEN \`repo_import_status\` = 'importing' THEN 'syncing'
|
||||
ELSE 'pending'
|
||||
END;
|
||||
`,
|
||||
} as const
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,84 @@ export const repos = sqliteTable("repos", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const handoffLookup = sqliteTable("handoff_lookup", {
|
||||
handoffId: text("handoff_id").notNull().primaryKey(),
|
||||
export const taskLookup = sqliteTable("task_lookup", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
});
|
||||
|
||||
export const organizationProfile = sqliteTable("organization_profile", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
kind: text("kind").notNull(),
|
||||
githubAccountId: text("github_account_id").notNull(),
|
||||
githubLogin: text("github_login").notNull(),
|
||||
githubAccountType: text("github_account_type").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
primaryDomain: text("primary_domain").notNull(),
|
||||
defaultModel: text("default_model").notNull(),
|
||||
autoImportRepos: integer("auto_import_repos").notNull(),
|
||||
repoImportStatus: text("repo_import_status").notNull(),
|
||||
githubConnectedAccount: text("github_connected_account").notNull(),
|
||||
githubInstallationStatus: text("github_installation_status").notNull(),
|
||||
githubSyncStatus: text("github_sync_status").notNull(),
|
||||
githubInstallationId: integer("github_installation_id"),
|
||||
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
||||
githubLastSyncAt: integer("github_last_sync_at"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
stripePriceId: text("stripe_price_id"),
|
||||
billingPlanId: text("billing_plan_id").notNull(),
|
||||
billingStatus: text("billing_status").notNull(),
|
||||
billingSeatsIncluded: integer("billing_seats_included").notNull(),
|
||||
billingTrialEndsAt: text("billing_trial_ends_at"),
|
||||
billingRenewalAt: text("billing_renewal_at"),
|
||||
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const organizationMembers = sqliteTable("organization_members", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
role: text("role").notNull(),
|
||||
state: text("state").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const seatAssignments = sqliteTable("seat_assignments", {
|
||||
email: text("email").notNull().primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const invoices = sqliteTable("invoices", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
label: text("label").notNull(),
|
||||
issuedAt: text("issued_at").notNull(),
|
||||
amountUsd: integer("amount_usd").notNull(),
|
||||
status: text("status").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const appSessions = sqliteTable("app_sessions", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
currentUserId: text("current_user_id"),
|
||||
currentUserName: text("current_user_name"),
|
||||
currentUserEmail: text("current_user_email"),
|
||||
currentUserGithubLogin: text("current_user_github_login"),
|
||||
currentUserRoleLabel: text("current_user_role_label"),
|
||||
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
githubAccessToken: text("github_access_token"),
|
||||
githubScope: text("github_scope").notNull(),
|
||||
oauthState: text("oauth_state"),
|
||||
oauthStateExpiresAt: integer("oauth_state_expires_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const stripeLookup = sqliteTable("stripe_lookup", {
|
||||
lookupKey: text("lookup_key").notNull().primaryKey(),
|
||||
organizationId: text("organization_id").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import { createBackends, createNotificationService } from "./notifications/index
|
|||
import { createDefaultDriver } from "./driver.js";
|
||||
import { createProviderRegistry } from "./providers/index.js";
|
||||
import { createClient } from "rivetkit/client";
|
||||
import { FactoryAppStore } from "./services/app-state.js";
|
||||
import type { FactoryBillingPlanId, FactoryOrganization } from "@sandbox-agent/factory-shared";
|
||||
import type { FactoryBillingPlanId } from "@sandbox-agent/factory-shared";
|
||||
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
|
||||
|
||||
export interface BackendStartOptions {
|
||||
host?: string;
|
||||
|
|
@ -18,6 +19,7 @@ export interface BackendStartOptions {
|
|||
}
|
||||
|
||||
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
|
||||
process.env.NODE_ENV ||= "development";
|
||||
loadDevelopmentEnvFiles();
|
||||
applyDevelopmentEnvDefaults();
|
||||
|
||||
|
|
@ -50,36 +52,15 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
const providers = createProviderRegistry(config, driver);
|
||||
const backends = await createBackends(config.notify);
|
||||
const notifications = createNotificationService(backends);
|
||||
initActorRuntimeContext(config, providers, notifications, driver);
|
||||
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
|
||||
|
||||
registry.startRunner();
|
||||
const inner = registry.serve();
|
||||
const actorClient = createClient({
|
||||
endpoint: `http://127.0.0.1:${resolveManagerPort()}`,
|
||||
disableMetadataLookup: true,
|
||||
}) as any;
|
||||
|
||||
const syncOrganizationRepos = async (organization: FactoryOrganization): Promise<void> => {
|
||||
const workspace = await actorClient.workspace.getOrCreate(workspaceKey(organization.workspaceId), {
|
||||
createWithInput: organization.workspaceId,
|
||||
});
|
||||
const existing = await workspace.listRepos({ workspaceId: organization.workspaceId });
|
||||
const existingRemotes = new Set(existing.map((repo: { remoteUrl: string }) => repo.remoteUrl));
|
||||
|
||||
for (const repo of organization.repoCatalog) {
|
||||
const remoteUrl = `mockgithub://${repo}`;
|
||||
if (existingRemotes.has(remoteUrl)) {
|
||||
continue;
|
||||
}
|
||||
await workspace.addRepo({
|
||||
workspaceId: organization.workspaceId,
|
||||
remoteUrl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const appStore = new FactoryAppStore({
|
||||
onOrganizationReposReady: syncOrganizationRepos,
|
||||
});
|
||||
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
|
||||
|
||||
// Wrap in a Hono app mounted at /api/rivet to serve on the backend port.
|
||||
|
|
@ -87,14 +68,31 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
// 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();
|
||||
const allowHeaders = [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"x-rivet-token",
|
||||
"x-rivet-encoding",
|
||||
"x-rivet-query",
|
||||
"x-rivet-conn-params",
|
||||
"x-rivet-actor",
|
||||
"x-rivet-target",
|
||||
"x-rivet-namespace",
|
||||
"x-rivet-endpoint",
|
||||
"x-rivet-total-slots",
|
||||
"x-rivet-runner-name",
|
||||
"x-rivet-namespace-name",
|
||||
"x-factory-session",
|
||||
];
|
||||
const exposeHeaders = ["Content-Type", "x-factory-session", "x-rivet-ray-id"];
|
||||
app.use(
|
||||
"/api/rivet/*",
|
||||
cors({
|
||||
origin: (origin) => origin ?? "*",
|
||||
credentials: true,
|
||||
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token", "x-factory-session"],
|
||||
allowHeaders,
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Type", "x-factory-session"],
|
||||
exposeHeaders,
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
|
|
@ -102,44 +100,68 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
cors({
|
||||
origin: (origin) => origin ?? "*",
|
||||
credentials: true,
|
||||
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token", "x-factory-session"],
|
||||
allowHeaders,
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Type", "x-factory-session"],
|
||||
exposeHeaders,
|
||||
})
|
||||
);
|
||||
const resolveSessionId = (c: any): string => {
|
||||
const appWorkspace = async () =>
|
||||
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const resolveSessionId = async (c: any): Promise<string> => {
|
||||
const requested = c.req.header("x-factory-session");
|
||||
const sessionId = appStore.ensureSession(requested);
|
||||
const { sessionId } = await (await appWorkspace()).ensureAppSession({
|
||||
requestedSessionId: requested ?? null,
|
||||
});
|
||||
c.header("x-factory-session", sessionId);
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
app.get("/api/rivet/app/snapshot", (c) => {
|
||||
const sessionId = resolveSessionId(c);
|
||||
return c.json(appStore.getSnapshot(sessionId));
|
||||
app.get("/api/rivet/app/snapshot", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await (await appWorkspace()).getAppSnapshot({ sessionId }));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/sign-in", async (c) => {
|
||||
const sessionId = resolveSessionId(c);
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const userId = typeof body?.userId === "string" ? body.userId : undefined;
|
||||
return c.json(appStore.signInWithGithub(sessionId, userId));
|
||||
app.get("/api/rivet/app/auth/github/start", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const result = await (await appWorkspace()).startAppGithubAuth({ sessionId });
|
||||
return Response.redirect(result.url, 302);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/sign-out", (c) => {
|
||||
const sessionId = resolveSessionId(c);
|
||||
return c.json(appStore.signOut(sessionId));
|
||||
app.get("/api/rivet/app/auth/github/callback", async (c) => {
|
||||
const code = c.req.query("code");
|
||||
const state = c.req.query("state");
|
||||
if (!code || !state) {
|
||||
return c.text("Missing GitHub OAuth callback parameters", 400);
|
||||
}
|
||||
const result = await (await appWorkspace()).completeAppGithubAuth({ code, state });
|
||||
c.header("x-factory-session", result.sessionId);
|
||||
return Response.redirect(result.redirectTo, 302);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/sign-out", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await (await appWorkspace()).signOutApp({ sessionId }));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/select", async (c) => {
|
||||
const sessionId = resolveSessionId(c);
|
||||
return c.json(await appStore.selectOrganization(sessionId, c.req.param("organizationId")));
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.patch("/api/rivet/app/organizations/:organizationId/profile", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
appStore.updateOrganizationProfile({
|
||||
await (await appWorkspace()).updateAppOrganizationProfile({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
displayName: typeof body?.displayName === "string" ? body.displayName : "",
|
||||
slug: typeof body?.slug === "string" ? body.slug : "",
|
||||
|
|
@ -149,38 +171,116 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => {
|
||||
return c.json(await appStore.triggerRepoImport(c.req.param("organizationId")));
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/reconnect", (c) => {
|
||||
return c.json(appStore.reconnectGithub(c.req.param("organizationId")));
|
||||
app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).beginAppGithubInstall({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/checkout", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const planId =
|
||||
body?.planId === "free" || body?.planId === "team" || body?.planId === "enterprise"
|
||||
? (body.planId as FactoryBillingPlanId)
|
||||
: "team";
|
||||
return c.json(appStore.completeHostedCheckout(c.req.param("organizationId"), planId));
|
||||
const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FactoryBillingPlanId) : "team";
|
||||
return c.json(
|
||||
await (await appWorkspace()).createAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
planId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/cancel", (c) => {
|
||||
return c.json(appStore.cancelScheduledRenewal(c.req.param("organizationId")));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/resume", (c) => {
|
||||
return c.json(appStore.resumeSubscription(c.req.param("organizationId")));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/workspaces/:workspaceId/seat-usage", (c) => {
|
||||
const sessionId = resolveSessionId(c);
|
||||
const workspaceId = c.req.param("workspaceId");
|
||||
const userEmail = appStore.findUserEmailForWorkspace(workspaceId, sessionId);
|
||||
if (userEmail) {
|
||||
appStore.recordSeatUsage(workspaceId, userEmail);
|
||||
app.get("/api/rivet/app/billing/checkout/complete", async (c) => {
|
||||
const organizationId = c.req.query("organizationId");
|
||||
const sessionId = c.req.query("factorySession");
|
||||
const checkoutSessionId = c.req.query("session_id");
|
||||
if (!organizationId || !sessionId || !checkoutSessionId) {
|
||||
return c.text("Missing Stripe checkout completion parameters", 400);
|
||||
}
|
||||
return c.json(appStore.getSnapshot(sessionId));
|
||||
const result = await (await appWorkspace()).finalizeAppCheckoutSession({
|
||||
organizationId,
|
||||
sessionId,
|
||||
checkoutSessionId,
|
||||
});
|
||||
return Response.redirect(result.redirectTo, 302);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/portal", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).createAppBillingPortalSession({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/cancel", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).cancelAppScheduledRenewal({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/resume", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).resumeAppSubscription({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const handleStripeWebhook = async (c: any) => {
|
||||
const payload = await c.req.text();
|
||||
await (await appWorkspace()).handleAppStripeWebhook({
|
||||
payload,
|
||||
signatureHeader: c.req.header("stripe-signature") ?? null,
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
};
|
||||
|
||||
app.post("/api/rivet/app/webhooks/stripe", handleStripeWebhook);
|
||||
app.post("/api/rivet/app/stripe/webhook", handleStripeWebhook);
|
||||
|
||||
const handleGithubWebhook = async (c: any) => {
|
||||
const payload = await c.req.text();
|
||||
await (await appWorkspace()).handleAppGithubWebhook({
|
||||
payload,
|
||||
signatureHeader: c.req.header("x-hub-signature-256") ?? null,
|
||||
eventHeader: c.req.header("x-github-event") ?? null,
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
};
|
||||
|
||||
app.post("/api/rivet/app/webhooks/github", handleGithubWebhook);
|
||||
|
||||
app.post("/api/rivet/app/workspaces/:workspaceId/seat-usage", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const workspaceId = c.req.param("workspaceId");
|
||||
return c.json(
|
||||
await (await appWorkspace()).recordAppSeatUsage({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const proxyManagerRequest = async (c: any) => {
|
||||
|
|
@ -192,7 +292,17 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
const forward = async (c: any) => {
|
||||
try {
|
||||
const pathname = new URL(c.req.url).pathname;
|
||||
if (pathname === "/api/rivet/app/webhooks/stripe" || pathname === "/api/rivet/app/stripe/webhook") {
|
||||
return await handleStripeWebhook(c);
|
||||
}
|
||||
if (pathname === "/api/rivet/app/webhooks/github") {
|
||||
return await handleGithubWebhook(c);
|
||||
}
|
||||
if (pathname.startsWith("/api/rivet/app/")) {
|
||||
return c.text("Not Found", 404);
|
||||
}
|
||||
if (
|
||||
pathname === "/api/rivet/metadata" ||
|
||||
pathname === "/api/rivet/actors" ||
|
||||
pathname.startsWith("/api/rivet/actors/") ||
|
||||
pathname.startsWith("/api/rivet/gateway/")
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export class SandboxAgentClient {
|
|||
// If the agent doesn't support session modes, ignore.
|
||||
//
|
||||
// Do this in the background: ACP mode updates can occasionally time out (504),
|
||||
// and waiting here can stall session creation long enough to trip handoff init
|
||||
// and waiting here can stall session creation long enough to trip task init
|
||||
// step timeouts even though the session itself was created.
|
||||
if (modeId) {
|
||||
void session.send("session/set_mode", { modeId }).catch(() => {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface NotificationService {
|
|||
prApproved(branchName: string, prNumber: number, reviewer: string): Promise<void>;
|
||||
changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void>;
|
||||
prMerged(branchName: string, prNumber: number): Promise<void>;
|
||||
handoffCreated(branchName: string): Promise<void>;
|
||||
taskCreated(branchName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createNotificationService(backends: NotifyBackend[]): NotificationService {
|
||||
|
|
@ -60,8 +60,8 @@ export function createNotificationService(backends: NotifyBackend[]): Notificati
|
|||
await notify("PR Merged", `PR #${prNumber} on ${branchName} merged`, "normal");
|
||||
},
|
||||
|
||||
async handoffCreated(branchName: string): Promise<void> {
|
||||
await notify("Handoff Created", `New handoff on ${branchName}`, "low");
|
||||
async taskCreated(branchName: string): Promise<void> {
|
||||
await notify("Task Created", `New task on ${branchName}`, "low");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
emitDebug("daytona.createSandbox.start", {
|
||||
workspaceId: req.workspaceId,
|
||||
repoId: req.repoId,
|
||||
handoffId: req.handoffId,
|
||||
taskId: req.taskId,
|
||||
branchName: req.branchName
|
||||
});
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
envVars: this.buildEnvVars(),
|
||||
labels: {
|
||||
"factory.workspace": req.workspaceId,
|
||||
"factory.handoff": req.handoffId,
|
||||
"factory.task": req.taskId,
|
||||
"factory.repo_id": req.repoId,
|
||||
"factory.repo_remote": req.repoRemote,
|
||||
"factory.branch": req.branchName,
|
||||
|
|
@ -220,9 +220,9 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
state: sandbox.state ?? null
|
||||
});
|
||||
|
||||
const repoDir = `/home/daytona/sandbox-agent-factory/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
|
||||
const repoDir = `/home/daytona/sandbox-agent-factory/${req.workspaceId}/${req.repoId}/${req.taskId}/repo`;
|
||||
|
||||
// Prepare a working directory for the agent. This must succeed for the handoff to work.
|
||||
// Prepare a working directory for the agent. This must succeed for the task to work.
|
||||
const installStartedAt = Date.now();
|
||||
await this.runCheckedCommand(
|
||||
sandbox.id,
|
||||
|
|
@ -256,7 +256,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
`git clone "${req.repoRemote}" "${repoDir}"`,
|
||||
`cd "${repoDir}"`,
|
||||
`git fetch origin --prune`,
|
||||
// The handoff branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
|
||||
// The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
|
||||
`if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`,
|
||||
`git config user.email "factory@local" >/dev/null 2>&1 || true`,
|
||||
`git config user.name "Sandbox Agent Factory" >/dev/null 2>&1 || true`,
|
||||
|
|
@ -296,10 +296,10 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
const labels = info.labels ?? {};
|
||||
const workspaceId = labels["factory.workspace"] ?? req.workspaceId;
|
||||
const repoId = labels["factory.repo_id"] ?? "";
|
||||
const handoffId = labels["factory.handoff"] ?? "";
|
||||
const taskId = labels["factory.task"] ?? "";
|
||||
const cwd =
|
||||
repoId && handoffId
|
||||
? `/home/daytona/sandbox-agent-factory/${workspaceId}/${repoId}/${handoffId}/repo`
|
||||
repoId && taskId
|
||||
? `/home/daytona/sandbox-agent-factory/${workspaceId}/${repoId}/${taskId}/repo`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export class LocalProvider implements SandboxProvider {
|
|||
}
|
||||
|
||||
async createSandbox(req: CreateSandboxRequest): Promise<SandboxHandle> {
|
||||
const sandboxId = req.handoffId || `local-${randomUUID()}`;
|
||||
const sandboxId = req.taskId || `local-${randomUUID()}`;
|
||||
const repoDir = this.repoDir(req.workspaceId, sandboxId);
|
||||
mkdirSync(dirname(repoDir), { recursive: true });
|
||||
await this.git.ensureCloned(req.repoRemote, repoDir);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface CreateSandboxRequest {
|
|||
repoId: string;
|
||||
repoRemote: string;
|
||||
branchName: string;
|
||||
handoffId: string;
|
||||
taskId: string;
|
||||
debug?: (message: string, context?: Record<string, unknown>) => void;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
503
factory/packages/backend/src/services/app-github.ts
Normal file
503
factory/packages/backend/src/services/app-github.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export class GitHubAppError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 500) {
|
||||
super(message);
|
||||
this.name = "GitHubAppError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubOAuthSession {
|
||||
accessToken: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface GitHubViewerIdentity {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface GitHubOrgIdentity {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface GitHubInstallationRecord {
|
||||
id: number;
|
||||
accountLogin: string;
|
||||
}
|
||||
|
||||
export interface GitHubRepositoryRecord {
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface GitHubTokenResponse {
|
||||
access_token?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface GitHubPageResponse<T> {
|
||||
items: T[];
|
||||
nextUrl: string | null;
|
||||
}
|
||||
|
||||
export interface GitHubWebhookEvent {
|
||||
action?: string;
|
||||
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
||||
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
|
||||
repositories_removed?: Array<{ id: number; full_name: string }>;
|
||||
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
|
||||
pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } };
|
||||
sender?: { login?: string; id?: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GitHubAppClientOptions {
|
||||
apiBaseUrl?: string;
|
||||
authBaseUrl?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
redirectUri?: string;
|
||||
appId?: string;
|
||||
appPrivateKey?: string;
|
||||
webhookSecret?: string;
|
||||
}
|
||||
|
||||
export class GitHubAppClient {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly authBaseUrl: string;
|
||||
private readonly clientId?: string;
|
||||
private readonly clientSecret?: string;
|
||||
private readonly redirectUri?: string;
|
||||
private readonly appId?: string;
|
||||
private readonly appPrivateKey?: string;
|
||||
private readonly webhookSecret?: string;
|
||||
|
||||
constructor(options: GitHubAppClientOptions = {}) {
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, "");
|
||||
this.authBaseUrl = (options.authBaseUrl ?? "https://github.com").replace(/\/$/, "");
|
||||
this.clientId = options.clientId ?? process.env.GITHUB_CLIENT_ID;
|
||||
this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET;
|
||||
this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI;
|
||||
this.appId = options.appId ?? process.env.GITHUB_APP_ID;
|
||||
this.appPrivateKey = options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET;
|
||||
}
|
||||
|
||||
isOauthConfigured(): boolean {
|
||||
return Boolean(this.clientId && this.clientSecret && this.redirectUri);
|
||||
}
|
||||
|
||||
isAppConfigured(): boolean {
|
||||
return Boolean(this.appId && this.appPrivateKey);
|
||||
}
|
||||
|
||||
isWebhookConfigured(): boolean {
|
||||
return Boolean(this.webhookSecret);
|
||||
}
|
||||
|
||||
verifyWebhookEvent(payload: string, signatureHeader: string | null, eventHeader: string | null): { event: string; body: GitHubWebhookEvent } {
|
||||
if (!this.webhookSecret) {
|
||||
throw new GitHubAppError("GitHub webhook secret is not configured", 500);
|
||||
}
|
||||
if (!signatureHeader) {
|
||||
throw new GitHubAppError("Missing GitHub signature header", 400);
|
||||
}
|
||||
if (!eventHeader) {
|
||||
throw new GitHubAppError("Missing GitHub event header", 400);
|
||||
}
|
||||
|
||||
const expectedSignature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : null;
|
||||
if (!expectedSignature) {
|
||||
throw new GitHubAppError("Malformed GitHub signature header", 400);
|
||||
}
|
||||
|
||||
const computed = createHmac("sha256", this.webhookSecret).update(payload).digest("hex");
|
||||
const computedBuffer = Buffer.from(computed, "utf8");
|
||||
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
|
||||
if (computedBuffer.length !== expectedBuffer.length || !timingSafeEqual(computedBuffer, expectedBuffer)) {
|
||||
throw new GitHubAppError("GitHub webhook signature verification failed", 400);
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHeader,
|
||||
body: JSON.parse(payload) as GitHubWebhookEvent,
|
||||
};
|
||||
}
|
||||
|
||||
buildAuthorizeUrl(state: string): string {
|
||||
if (!this.clientId || !this.redirectUri) {
|
||||
throw new GitHubAppError("GitHub OAuth is not configured", 500);
|
||||
}
|
||||
|
||||
const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`);
|
||||
url.searchParams.set("client_id", this.clientId);
|
||||
url.searchParams.set("redirect_uri", this.redirectUri);
|
||||
url.searchParams.set("scope", "read:user user:email read:org");
|
||||
url.searchParams.set("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<GitHubOAuthSession> {
|
||||
if (!this.clientId || !this.clientSecret || !this.redirectUri) {
|
||||
throw new GitHubAppError("GitHub OAuth is not configured", 500);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let payload: GitHubTokenResponse;
|
||||
try {
|
||||
payload = JSON.parse(responseText) as GitHubTokenResponse;
|
||||
} catch {
|
||||
// GitHub may return URL-encoded responses despite Accept: application/json
|
||||
const params = new URLSearchParams(responseText);
|
||||
if (params.has("access_token")) {
|
||||
payload = {
|
||||
access_token: params.get("access_token")!,
|
||||
scope: params.get("scope") ?? "",
|
||||
};
|
||||
} else {
|
||||
throw new GitHubAppError(
|
||||
params.get("error_description") ?? params.get("error") ?? `GitHub token exchange failed: ${responseText.slice(0, 200)}`,
|
||||
response.status || 502,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response.ok || !payload.access_token) {
|
||||
throw new GitHubAppError(
|
||||
payload.error_description ?? payload.error ?? `GitHub token exchange failed with ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: payload.access_token,
|
||||
scopes: payload.scope
|
||||
?.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async getViewer(accessToken: string): Promise<GitHubViewerIdentity> {
|
||||
const user = await this.requestJson<{
|
||||
id: number;
|
||||
login: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}>("/user", accessToken);
|
||||
|
||||
let email = user.email ?? null;
|
||||
if (!email) {
|
||||
try {
|
||||
const emails = await this.requestJson<Array<{ email: string; primary?: boolean; verified?: boolean }>>(
|
||||
"/user/emails",
|
||||
accessToken,
|
||||
);
|
||||
const primary = emails.find((candidate) => candidate.primary && candidate.verified) ?? emails[0] ?? null;
|
||||
email = primary?.email ?? null;
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(user.id),
|
||||
login: user.login,
|
||||
name: user.name?.trim() || user.login,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
|
||||
const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>(
|
||||
"/user/orgs?per_page=100",
|
||||
accessToken,
|
||||
);
|
||||
return organizations.map((organization) => ({
|
||||
id: String(organization.id),
|
||||
login: organization.login,
|
||||
name: organization.description?.trim() || organization.login,
|
||||
}));
|
||||
}
|
||||
|
||||
async listInstallations(accessToken: string): Promise<GitHubInstallationRecord[]> {
|
||||
if (!this.isAppConfigured()) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const payload = await this.requestJson<{
|
||||
installations?: Array<{ id: number; account?: { login?: string } | null }>;
|
||||
}>("/user/installations", accessToken);
|
||||
|
||||
return (payload.installations ?? [])
|
||||
.map((installation) => ({
|
||||
id: installation.id,
|
||||
accountLogin: installation.account?.login?.trim() ?? "",
|
||||
}))
|
||||
.filter((installation) => installation.accountLogin.length > 0);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || (error.status !== 401 && error.status !== 403)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const installations = await this.paginateApp<{ id: number; account?: { login?: string } | null }>(
|
||||
"/app/installations?per_page=100",
|
||||
);
|
||||
return installations
|
||||
.map((installation) => ({
|
||||
id: installation.id,
|
||||
accountLogin: installation.account?.login?.trim() ?? "",
|
||||
}))
|
||||
.filter((installation) => installation.accountLogin.length > 0);
|
||||
}
|
||||
|
||||
async listUserRepositories(accessToken: string): Promise<GitHubRepositoryRecord[]> {
|
||||
const repositories = await this.paginate<{
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}>("/user/repos?per_page=100&affiliation=owner&sort=updated", accessToken);
|
||||
|
||||
return repositories.map((repository) => ({
|
||||
fullName: repository.full_name,
|
||||
cloneUrl: repository.clone_url,
|
||||
private: repository.private,
|
||||
}));
|
||||
}
|
||||
|
||||
async listInstallationRepositories(installationId: number): Promise<GitHubRepositoryRecord[]> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
const repositories = await this.paginate<{
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}>("/installation/repositories?per_page=100", accessToken);
|
||||
|
||||
return repositories.map((repository) => ({
|
||||
fullName: repository.full_name,
|
||||
cloneUrl: repository.clone_url,
|
||||
private: repository.private,
|
||||
}));
|
||||
}
|
||||
|
||||
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
|
||||
if (!this.isAppConfigured()) {
|
||||
throw new GitHubAppError("GitHub App is not configured", 500);
|
||||
}
|
||||
const app = await this.requestAppJson<{ slug?: string }>("/app");
|
||||
if (!app.slug) {
|
||||
throw new GitHubAppError("GitHub App slug is unavailable", 500);
|
||||
}
|
||||
const url = new URL(`${this.authBaseUrl}/apps/${app.slug}/installations/new`);
|
||||
url.searchParams.set("state", state);
|
||||
void organizationLogin;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async createInstallationAccessToken(installationId: number): Promise<string> {
|
||||
if (!this.appId || !this.appPrivateKey) {
|
||||
throw new GitHubAppError("GitHub App is not configured", 500);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiBaseUrl}/app/installations/${installationId}/access_tokens`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${this.createAppJwt()}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as { token?: string; message?: string };
|
||||
if (!response.ok || !payload.token) {
|
||||
throw new GitHubAppError(payload.message ?? "Unable to mint GitHub installation token", response.status);
|
||||
}
|
||||
return payload.token;
|
||||
}
|
||||
|
||||
private createAppJwt(): string {
|
||||
if (!this.appId || !this.appPrivateKey) {
|
||||
throw new GitHubAppError("GitHub App is not configured", 500);
|
||||
}
|
||||
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = base64UrlEncode(
|
||||
JSON.stringify({
|
||||
iat: now - 60,
|
||||
exp: now + 540,
|
||||
iss: this.appId,
|
||||
}),
|
||||
);
|
||||
const signer = createSign("RSA-SHA256");
|
||||
signer.update(`${header}.${payload}`);
|
||||
signer.end();
|
||||
const key = createPrivateKey(this.appPrivateKey);
|
||||
const signature = signer.sign(key);
|
||||
return `${header}.${payload}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
private async requestAppJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${this.createAppJwt()}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T | { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new GitHubAppError(
|
||||
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async paginateApp<T>(path: string): Promise<T[]> {
|
||||
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
const items: T[] = [];
|
||||
|
||||
while (nextUrl) {
|
||||
const page = await this.requestAppPage<T>(nextUrl);
|
||||
items.push(...page.items);
|
||||
nextUrl = page.nextUrl ?? "";
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, accessToken: string): Promise<T> {
|
||||
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T | { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new GitHubAppError(
|
||||
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
|
||||
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
const items: T[] = [];
|
||||
|
||||
while (nextUrl) {
|
||||
const page = await this.requestPage<T>(nextUrl, accessToken);
|
||||
items.push(...page.items);
|
||||
nextUrl = page.nextUrl ?? "";
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async requestPage<T>(url: string, accessToken: string): Promise<GitHubPageResponse<T>> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T[] | { repositories?: T[]; message?: string };
|
||||
if (!response.ok) {
|
||||
throw new GitHubAppError(
|
||||
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const items = Array.isArray(payload) ? payload : payload.repositories ?? [];
|
||||
return {
|
||||
items,
|
||||
nextUrl: parseNextLink(response.headers.get("link")),
|
||||
};
|
||||
}
|
||||
|
||||
private async requestAppPage<T>(url: string): Promise<GitHubPageResponse<T>> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${this.createAppJwt()}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T[] | { installations?: T[]; message?: string };
|
||||
if (!response.ok) {
|
||||
throw new GitHubAppError(
|
||||
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const items = Array.isArray(payload) ? payload : payload.installations ?? [];
|
||||
return {
|
||||
items,
|
||||
nextUrl: parseNextLink(response.headers.get("link")),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseNextLink(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of linkHeader.split(",")) {
|
||||
const [urlPart, relPart] = part.split(";").map((value) => value.trim());
|
||||
if (!urlPart || !relPart || !relPart.includes('rel="next"')) {
|
||||
continue;
|
||||
}
|
||||
return urlPart.replace(/^<|>$/g, "");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string | Buffer): string {
|
||||
const source = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
||||
return source
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
68
factory/packages/backend/src/services/app-shell-runtime.ts
Normal file
68
factory/packages/backend/src/services/app-shell-runtime.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { GitHubAppClient, type GitHubInstallationRecord, type GitHubOAuthSession, type GitHubOrgIdentity, type GitHubRepositoryRecord, type GitHubViewerIdentity, type GitHubWebhookEvent } from "./app-github.js";
|
||||
import { StripeAppClient, type StripeCheckoutCompletion, type StripeCheckoutSession, type StripePortalSession, type StripeSubscriptionSnapshot, type StripeWebhookEvent } from "./app-stripe.js";
|
||||
import type { FactoryBillingPlanId } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export type AppShellGithubClient = Pick<
|
||||
GitHubAppClient,
|
||||
| "isAppConfigured"
|
||||
| "isWebhookConfigured"
|
||||
| "buildAuthorizeUrl"
|
||||
| "exchangeCode"
|
||||
| "getViewer"
|
||||
| "listOrganizations"
|
||||
| "listInstallations"
|
||||
| "listUserRepositories"
|
||||
| "listInstallationRepositories"
|
||||
| "buildInstallationUrl"
|
||||
| "verifyWebhookEvent"
|
||||
>;
|
||||
|
||||
export type AppShellStripeClient = Pick<
|
||||
StripeAppClient,
|
||||
| "isConfigured"
|
||||
| "createCustomer"
|
||||
| "createCheckoutSession"
|
||||
| "retrieveCheckoutCompletion"
|
||||
| "retrieveSubscription"
|
||||
| "createPortalSession"
|
||||
| "updateSubscriptionCancellation"
|
||||
| "verifyWebhookEvent"
|
||||
| "planIdForPriceId"
|
||||
>;
|
||||
|
||||
export interface AppShellServices {
|
||||
appUrl: string;
|
||||
github: AppShellGithubClient;
|
||||
stripe: AppShellStripeClient;
|
||||
}
|
||||
|
||||
export interface CreateAppShellServicesOptions {
|
||||
appUrl?: string;
|
||||
github?: AppShellGithubClient;
|
||||
stripe?: AppShellStripeClient;
|
||||
}
|
||||
|
||||
export function createDefaultAppShellServices(
|
||||
options: CreateAppShellServicesOptions = {},
|
||||
): AppShellServices {
|
||||
return {
|
||||
appUrl: (options.appUrl ?? process.env.APP_URL ?? "http://localhost:4173").replace(/\/$/, ""),
|
||||
github: options.github ?? new GitHubAppClient(),
|
||||
stripe: options.stripe ?? new StripeAppClient(),
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
GitHubInstallationRecord,
|
||||
GitHubOAuthSession,
|
||||
GitHubOrgIdentity,
|
||||
GitHubRepositoryRecord,
|
||||
GitHubViewerIdentity,
|
||||
GitHubWebhookEvent,
|
||||
StripeCheckoutCompletion,
|
||||
StripeCheckoutSession,
|
||||
StripePortalSession,
|
||||
StripeSubscriptionSnapshot,
|
||||
StripeWebhookEvent,
|
||||
FactoryBillingPlanId,
|
||||
};
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
FactoryOrganization,
|
||||
FactoryUser,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
|
||||
interface PersistedFactorySession {
|
||||
sessionId: string;
|
||||
currentUserId: string | null;
|
||||
activeOrganizationId: string | null;
|
||||
}
|
||||
|
||||
interface PersistedFactoryAppState {
|
||||
users: FactoryUser[];
|
||||
organizations: FactoryOrganization[];
|
||||
sessions: PersistedFactorySession[];
|
||||
}
|
||||
|
||||
function nowIso(daysFromNow = 0): string {
|
||||
const value = new Date();
|
||||
value.setDate(value.getDate() + daysFromNow);
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function planSeatsIncluded(planId: FactoryBillingPlanId): number {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return 1;
|
||||
case "team":
|
||||
return 5;
|
||||
case "enterprise":
|
||||
return 25;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultState(): PersistedFactoryAppState {
|
||||
return {
|
||||
users: [
|
||||
{
|
||||
id: "user-nathan",
|
||||
name: "Nathan",
|
||||
email: "nathan@acme.dev",
|
||||
githubLogin: "nathan",
|
||||
roleLabel: "Founder",
|
||||
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
|
||||
},
|
||||
],
|
||||
organizations: [
|
||||
{
|
||||
id: "personal-nathan",
|
||||
workspaceId: "personal-nathan",
|
||||
kind: "personal",
|
||||
settings: {
|
||||
displayName: "Nathan",
|
||||
slug: "nathan",
|
||||
primaryDomain: "personal",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "claude-sonnet-4",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "nathan",
|
||||
installationStatus: "connected",
|
||||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced just now",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
status: "active",
|
||||
seatsIncluded: 1,
|
||||
trialEndsAt: null,
|
||||
renewalAt: null,
|
||||
stripeCustomerId: "cus_remote_personal_nathan",
|
||||
paymentMethodLabel: "No card required",
|
||||
invoices: [],
|
||||
},
|
||||
members: [
|
||||
{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
|
||||
],
|
||||
seatAssignments: ["nathan@acme.dev"],
|
||||
repoImportStatus: "ready",
|
||||
repoCatalog: ["nathan/personal-site"],
|
||||
},
|
||||
{
|
||||
id: "acme",
|
||||
workspaceId: "acme",
|
||||
kind: "organization",
|
||||
settings: {
|
||||
displayName: "Acme",
|
||||
slug: "acme",
|
||||
primaryDomain: "acme.dev",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "claude-sonnet-4",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "acme",
|
||||
installationStatus: "connected",
|
||||
importedRepoCount: 3,
|
||||
lastSyncLabel: "Waiting for first import",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
status: "active",
|
||||
seatsIncluded: 5,
|
||||
trialEndsAt: null,
|
||||
renewalAt: nowIso(18),
|
||||
stripeCustomerId: "cus_remote_acme_team",
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
invoices: [
|
||||
{ id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" },
|
||||
],
|
||||
},
|
||||
members: [
|
||||
{ id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
|
||||
{ id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" },
|
||||
{ id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" },
|
||||
],
|
||||
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
|
||||
repoImportStatus: "not_started",
|
||||
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
|
||||
},
|
||||
{
|
||||
id: "rivet",
|
||||
workspaceId: "rivet",
|
||||
kind: "organization",
|
||||
settings: {
|
||||
displayName: "Rivet",
|
||||
slug: "rivet",
|
||||
primaryDomain: "rivet.dev",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "o3",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "rivet-dev",
|
||||
installationStatus: "reconnect_required",
|
||||
importedRepoCount: 4,
|
||||
lastSyncLabel: "Sync stalled 2 hours ago",
|
||||
},
|
||||
billing: {
|
||||
planId: "enterprise",
|
||||
status: "trialing",
|
||||
seatsIncluded: 25,
|
||||
trialEndsAt: nowIso(12),
|
||||
renewalAt: nowIso(12),
|
||||
stripeCustomerId: "cus_remote_rivet_enterprise",
|
||||
paymentMethodLabel: "ACH verified",
|
||||
invoices: [
|
||||
{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" },
|
||||
],
|
||||
},
|
||||
members: [
|
||||
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
|
||||
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
|
||||
],
|
||||
seatAssignments: ["jamie@rivet.dev"],
|
||||
repoImportStatus: "not_started",
|
||||
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
|
||||
},
|
||||
],
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function githubRemote(repo: string): string {
|
||||
return `https://github.com/${repo}.git`;
|
||||
}
|
||||
|
||||
export interface FactoryAppStoreOptions {
|
||||
filePath?: string;
|
||||
onOrganizationReposReady?: (organization: FactoryOrganization) => Promise<void>;
|
||||
}
|
||||
|
||||
export class FactoryAppStore {
|
||||
private readonly filePath: string;
|
||||
private readonly onOrganizationReposReady?: (organization: FactoryOrganization) => Promise<void>;
|
||||
private state: PersistedFactoryAppState;
|
||||
private readonly importTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(options: FactoryAppStoreOptions = {}) {
|
||||
this.filePath =
|
||||
options.filePath ??
|
||||
join(process.cwd(), ".sandbox-agent-factory", "backend", "app-state.json");
|
||||
this.onOrganizationReposReady = options.onOrganizationReposReady;
|
||||
this.state = this.loadState();
|
||||
}
|
||||
|
||||
ensureSession(sessionId?: string | null): string {
|
||||
if (sessionId) {
|
||||
const existing = this.state.sessions.find((candidate) => candidate.sessionId === sessionId);
|
||||
if (existing) {
|
||||
return existing.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
const nextSessionId = randomUUID();
|
||||
this.state.sessions.push({
|
||||
sessionId: nextSessionId,
|
||||
currentUserId: null,
|
||||
activeOrganizationId: null,
|
||||
});
|
||||
this.persist();
|
||||
return nextSessionId;
|
||||
}
|
||||
|
||||
getSnapshot(sessionId: string): FactoryAppSnapshot {
|
||||
const session = this.requireSession(sessionId);
|
||||
return {
|
||||
auth: {
|
||||
status: session.currentUserId ? "signed_in" : "signed_out",
|
||||
currentUserId: session.currentUserId,
|
||||
},
|
||||
activeOrganizationId: session.activeOrganizationId,
|
||||
users: this.state.users,
|
||||
organizations: this.state.organizations,
|
||||
};
|
||||
}
|
||||
|
||||
signInWithGithub(sessionId: string, userId = "user-nathan"): FactoryAppSnapshot {
|
||||
const user = this.state.users.find((candidate) => candidate.id === userId);
|
||||
if (!user) {
|
||||
throw new Error(`Unknown user: ${userId}`);
|
||||
}
|
||||
|
||||
this.updateSession(sessionId, (session) => ({
|
||||
...session,
|
||||
currentUserId: userId,
|
||||
activeOrganizationId: user.eligibleOrganizationIds.length === 1 ? user.eligibleOrganizationIds[0] ?? null : null,
|
||||
}));
|
||||
|
||||
return this.getSnapshot(sessionId);
|
||||
}
|
||||
|
||||
signOut(sessionId: string): FactoryAppSnapshot {
|
||||
this.updateSession(sessionId, (session) => ({
|
||||
...session,
|
||||
currentUserId: null,
|
||||
activeOrganizationId: null,
|
||||
}));
|
||||
return this.getSnapshot(sessionId);
|
||||
}
|
||||
|
||||
async selectOrganization(sessionId: string, organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
const session = this.requireSession(sessionId);
|
||||
const user = this.requireSignedInUser(session);
|
||||
if (!user.eligibleOrganizationIds.includes(organizationId)) {
|
||||
throw new Error(`Organization ${organizationId} is not available to ${user.id}`);
|
||||
}
|
||||
|
||||
const organization = this.requireOrganization(organizationId);
|
||||
this.updateSession(sessionId, (current) => ({
|
||||
...current,
|
||||
activeOrganizationId: organizationId,
|
||||
}));
|
||||
|
||||
if (organization.repoImportStatus !== "ready") {
|
||||
await this.triggerRepoImport(organizationId);
|
||||
} else if (this.onOrganizationReposReady) {
|
||||
await this.onOrganizationReposReady(this.requireOrganization(organizationId));
|
||||
}
|
||||
|
||||
return this.getSnapshot(sessionId);
|
||||
}
|
||||
|
||||
updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): FactoryAppSnapshot {
|
||||
this.updateOrganization(input.organizationId, (organization) => ({
|
||||
...organization,
|
||||
settings: {
|
||||
...organization.settings,
|
||||
displayName: input.displayName.trim() || organization.settings.displayName,
|
||||
slug: input.slug.trim() || organization.settings.slug,
|
||||
primaryDomain: input.primaryDomain.trim() || organization.settings.primaryDomain,
|
||||
},
|
||||
}));
|
||||
return this.snapshotForOrganization(input.organizationId);
|
||||
}
|
||||
|
||||
async triggerRepoImport(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
const organization = this.requireOrganization(organizationId);
|
||||
const existingTimer = this.importTimers.get(organizationId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
this.updateOrganization(organizationId, (current) => ({
|
||||
...current,
|
||||
repoImportStatus: "importing",
|
||||
github: {
|
||||
...current.github,
|
||||
lastSyncLabel: "Importing repository catalog...",
|
||||
},
|
||||
}));
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
this.updateOrganization(organizationId, (current) => ({
|
||||
...current,
|
||||
repoImportStatus: "ready",
|
||||
github: {
|
||||
...current.github,
|
||||
importedRepoCount: current.repoCatalog.length,
|
||||
installationStatus: "connected",
|
||||
lastSyncLabel: "Synced just now",
|
||||
},
|
||||
}));
|
||||
|
||||
if (this.onOrganizationReposReady) {
|
||||
await this.onOrganizationReposReady(this.requireOrganization(organizationId));
|
||||
}
|
||||
|
||||
this.importTimers.delete(organizationId);
|
||||
}, organization.kind === "personal" ? 100 : 1_250);
|
||||
|
||||
this.importTimers.set(organizationId, timer);
|
||||
return this.snapshotForOrganization(organizationId);
|
||||
}
|
||||
|
||||
completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): FactoryAppSnapshot {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
planId,
|
||||
status: "active",
|
||||
seatsIncluded: planSeatsIncluded(planId),
|
||||
trialEndsAt: null,
|
||||
renewalAt: nowIso(30),
|
||||
paymentMethodLabel: planId === "enterprise" ? "ACH verified" : "Visa ending in 4242",
|
||||
invoices: [
|
||||
{
|
||||
id: `inv-${organizationId}-${Date.now()}`,
|
||||
label: `${organization.settings.displayName} ${planId} upgrade`,
|
||||
issuedAt: new Date().toISOString().slice(0, 10),
|
||||
amountUsd: planId === "team" ? 240 : planId === "enterprise" ? 1200 : 0,
|
||||
status: "paid",
|
||||
},
|
||||
...organization.billing.invoices,
|
||||
],
|
||||
},
|
||||
}));
|
||||
return this.snapshotForOrganization(organizationId);
|
||||
}
|
||||
|
||||
cancelScheduledRenewal(organizationId: string): FactoryAppSnapshot {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
status: "scheduled_cancel",
|
||||
},
|
||||
}));
|
||||
return this.snapshotForOrganization(organizationId);
|
||||
}
|
||||
|
||||
resumeSubscription(organizationId: string): FactoryAppSnapshot {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
status: "active",
|
||||
},
|
||||
}));
|
||||
return this.snapshotForOrganization(organizationId);
|
||||
}
|
||||
|
||||
reconnectGithub(organizationId: string): FactoryAppSnapshot {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
github: {
|
||||
...organization.github,
|
||||
installationStatus: "connected",
|
||||
lastSyncLabel: "Reconnected just now",
|
||||
},
|
||||
}));
|
||||
return this.snapshotForOrganization(organizationId);
|
||||
}
|
||||
|
||||
recordSeatUsage(workspaceId: string, userEmail: string): void {
|
||||
const organization = this.state.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
||||
if (!organization || organization.seatAssignments.includes(userEmail)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateOrganization(organization.id, (current) => ({
|
||||
...current,
|
||||
seatAssignments: [...current.seatAssignments, userEmail],
|
||||
}));
|
||||
}
|
||||
|
||||
organizationRepos(organizationId: string): string[] {
|
||||
return this.requireOrganization(organizationId).repoCatalog.map(githubRemote);
|
||||
}
|
||||
|
||||
findUserEmailForWorkspace(workspaceId: string, sessionId: string): string | null {
|
||||
const session = this.requireSession(sessionId);
|
||||
const user = session.currentUserId ? this.state.users.find((candidate) => candidate.id === session.currentUserId) : null;
|
||||
const organization = this.state.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
||||
if (!user || !organization) {
|
||||
return null;
|
||||
}
|
||||
return organization.members.some((member) => member.email === user.email) ? user.email : null;
|
||||
}
|
||||
|
||||
private loadState(): PersistedFactoryAppState {
|
||||
try {
|
||||
const raw = readFileSync(this.filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as PersistedFactoryAppState;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Invalid app state");
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
const initial = buildDefaultState();
|
||||
this.persistState(initial);
|
||||
return initial;
|
||||
}
|
||||
}
|
||||
|
||||
private snapshotForOrganization(organizationId: string): FactoryAppSnapshot {
|
||||
const session = this.state.sessions.find((candidate) => candidate.activeOrganizationId === organizationId);
|
||||
if (!session) {
|
||||
return {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
users: this.state.users,
|
||||
organizations: this.state.organizations,
|
||||
};
|
||||
}
|
||||
return this.getSnapshot(session.sessionId);
|
||||
}
|
||||
|
||||
private updateSession(
|
||||
sessionId: string,
|
||||
updater: (session: PersistedFactorySession) => PersistedFactorySession,
|
||||
): void {
|
||||
const session = this.requireSession(sessionId);
|
||||
this.state = {
|
||||
...this.state,
|
||||
sessions: this.state.sessions.map((candidate) => (candidate.sessionId === sessionId ? updater(session) : candidate)),
|
||||
};
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private updateOrganization(
|
||||
organizationId: string,
|
||||
updater: (organization: FactoryOrganization) => FactoryOrganization,
|
||||
): void {
|
||||
this.requireOrganization(organizationId);
|
||||
this.state = {
|
||||
...this.state,
|
||||
organizations: this.state.organizations.map((candidate) =>
|
||||
candidate.id === organizationId ? updater(candidate) : candidate,
|
||||
),
|
||||
};
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private requireSession(sessionId: string): PersistedFactorySession {
|
||||
const session = this.state.sessions.find((candidate) => candidate.sessionId === sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Unknown app session: ${sessionId}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private requireOrganization(organizationId: string): FactoryOrganization {
|
||||
const organization = this.state.organizations.find((candidate) => candidate.id === organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(`Unknown organization: ${organizationId}`);
|
||||
}
|
||||
return organization;
|
||||
}
|
||||
|
||||
private requireSignedInUser(session: PersistedFactorySession): FactoryUser {
|
||||
if (!session.currentUserId) {
|
||||
throw new Error("User must be signed in");
|
||||
}
|
||||
const user = this.state.users.find((candidate) => candidate.id === session.currentUserId);
|
||||
if (!user) {
|
||||
throw new Error(`Unknown user: ${session.currentUserId}`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
this.persistState(this.state);
|
||||
}
|
||||
|
||||
private persistState(state: PersistedFactoryAppState): void {
|
||||
mkdirSync(dirname(this.filePath), { recursive: true });
|
||||
writeFileSync(this.filePath, JSON.stringify(state, null, 2));
|
||||
}
|
||||
}
|
||||
308
factory/packages/backend/src/services/app-stripe.ts
Normal file
308
factory/packages/backend/src/services/app-stripe.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { FactoryBillingPlanId } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export class StripeAppError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 500) {
|
||||
super(message);
|
||||
this.name = "StripeAppError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StripeCheckoutSession {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StripePortalSession {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StripeSubscriptionSnapshot {
|
||||
id: string;
|
||||
customerId: string;
|
||||
priceId: string | null;
|
||||
status: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
currentPeriodEnd: number | null;
|
||||
trialEnd: number | null;
|
||||
defaultPaymentMethodLabel: string;
|
||||
}
|
||||
|
||||
export interface StripeCheckoutCompletion {
|
||||
customerId: string | null;
|
||||
subscriptionId: string | null;
|
||||
planId: FactoryBillingPlanId | null;
|
||||
paymentMethodLabel: string;
|
||||
}
|
||||
|
||||
export interface StripeWebhookEvent<T = unknown> {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: T;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StripeAppClientOptions {
|
||||
apiBaseUrl?: string;
|
||||
secretKey?: string;
|
||||
webhookSecret?: string;
|
||||
teamPriceId?: string;
|
||||
}
|
||||
|
||||
export class StripeAppClient {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly secretKey?: string;
|
||||
private readonly webhookSecret?: string;
|
||||
private readonly teamPriceId?: string;
|
||||
|
||||
constructor(options: StripeAppClientOptions = {}) {
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.stripe.com").replace(/\/$/, "");
|
||||
this.secretKey = options.secretKey ?? process.env.STRIPE_SECRET_KEY;
|
||||
this.webhookSecret = options.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET;
|
||||
this.teamPriceId = options.teamPriceId ?? process.env.STRIPE_PRICE_TEAM;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(this.secretKey);
|
||||
}
|
||||
|
||||
createCheckoutSession(input: {
|
||||
organizationId: string;
|
||||
customerId: string;
|
||||
customerEmail: string | null;
|
||||
planId: Exclude<FactoryBillingPlanId, "free">;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}): Promise<StripeCheckoutSession> {
|
||||
const priceId = this.priceIdForPlan(input.planId);
|
||||
return this.formRequest<StripeCheckoutSession>("/v1/checkout/sessions", {
|
||||
mode: "subscription",
|
||||
success_url: input.successUrl,
|
||||
cancel_url: input.cancelUrl,
|
||||
customer: input.customerId,
|
||||
"line_items[0][price]": priceId,
|
||||
"line_items[0][quantity]": "1",
|
||||
"metadata[organizationId]": input.organizationId,
|
||||
"metadata[planId]": input.planId,
|
||||
"subscription_data[metadata][organizationId]": input.organizationId,
|
||||
"subscription_data[metadata][planId]": input.planId,
|
||||
});
|
||||
}
|
||||
|
||||
createPortalSession(input: { customerId: string; returnUrl: string }): Promise<StripePortalSession> {
|
||||
return this.formRequest<StripePortalSession>("/v1/billing_portal/sessions", {
|
||||
customer: input.customerId,
|
||||
return_url: input.returnUrl,
|
||||
});
|
||||
}
|
||||
|
||||
createCustomer(input: {
|
||||
organizationId: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
}): Promise<{ id: string }> {
|
||||
return this.formRequest<{ id: string }>("/v1/customers", {
|
||||
name: input.displayName,
|
||||
...(input.email ? { email: input.email } : {}),
|
||||
"metadata[organizationId]": input.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateSubscriptionCancellation(
|
||||
subscriptionId: string,
|
||||
cancelAtPeriodEnd: boolean,
|
||||
): Promise<StripeSubscriptionSnapshot> {
|
||||
const payload = await this.formRequest<Record<string, unknown>>(`/v1/subscriptions/${subscriptionId}`, {
|
||||
cancel_at_period_end: cancelAtPeriodEnd ? "true" : "false",
|
||||
});
|
||||
return stripeSubscriptionSnapshot(payload);
|
||||
}
|
||||
|
||||
async retrieveCheckoutCompletion(sessionId: string): Promise<StripeCheckoutCompletion> {
|
||||
const payload = await this.requestJson<Record<string, unknown>>(
|
||||
`/v1/checkout/sessions/${sessionId}?expand[]=subscription.default_payment_method`,
|
||||
);
|
||||
|
||||
const subscription =
|
||||
typeof payload.subscription === "object" && payload.subscription ? (payload.subscription as Record<string, unknown>) : null;
|
||||
const subscriptionId =
|
||||
typeof payload.subscription === "string"
|
||||
? payload.subscription
|
||||
: subscription && typeof subscription.id === "string"
|
||||
? subscription.id
|
||||
: null;
|
||||
const priceId = firstStripePriceId(subscription);
|
||||
|
||||
return {
|
||||
customerId: typeof payload.customer === "string" ? payload.customer : null,
|
||||
subscriptionId,
|
||||
planId: priceId ? this.planIdForPriceId(priceId) : planIdFromMetadata(payload.metadata),
|
||||
paymentMethodLabel: subscription ? paymentMethodLabelFromObject(subscription.default_payment_method) : "Card on file",
|
||||
};
|
||||
}
|
||||
|
||||
async retrieveSubscription(subscriptionId: string): Promise<StripeSubscriptionSnapshot> {
|
||||
const payload = await this.requestJson<Record<string, unknown>>(
|
||||
`/v1/subscriptions/${subscriptionId}?expand[]=default_payment_method`,
|
||||
);
|
||||
return stripeSubscriptionSnapshot(payload);
|
||||
}
|
||||
|
||||
verifyWebhookEvent(payload: string, signatureHeader: string | null): StripeWebhookEvent {
|
||||
if (!this.webhookSecret) {
|
||||
throw new StripeAppError("Stripe webhook secret is not configured", 500);
|
||||
}
|
||||
if (!signatureHeader) {
|
||||
throw new StripeAppError("Missing Stripe signature header", 400);
|
||||
}
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
signatureHeader
|
||||
.split(",")
|
||||
.map((entry) => entry.split("="))
|
||||
.filter((entry): entry is [string, string] => entry.length === 2),
|
||||
);
|
||||
const timestamp = parts.t;
|
||||
const signature = parts.v1;
|
||||
if (!timestamp || !signature) {
|
||||
throw new StripeAppError("Malformed Stripe signature header", 400);
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", this.webhookSecret)
|
||||
.update(`${timestamp}.${payload}`)
|
||||
.digest("hex");
|
||||
|
||||
const expectedBuffer = Buffer.from(expected, "utf8");
|
||||
const actualBuffer = Buffer.from(signature, "utf8");
|
||||
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
|
||||
throw new StripeAppError("Stripe signature verification failed", 400);
|
||||
}
|
||||
|
||||
return JSON.parse(payload) as StripeWebhookEvent;
|
||||
}
|
||||
|
||||
planIdForPriceId(priceId: string): FactoryBillingPlanId | null {
|
||||
if (priceId === this.teamPriceId) {
|
||||
return "team";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
priceIdForPlan(planId: Exclude<FactoryBillingPlanId, "free">): string {
|
||||
const priceId = this.teamPriceId;
|
||||
if (!priceId) {
|
||||
throw new StripeAppError(`Stripe price ID is not configured for ${planId}`, 500);
|
||||
}
|
||||
return priceId;
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string): Promise<T> {
|
||||
if (!this.secretKey) {
|
||||
throw new StripeAppError("Stripe is not configured", 500);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.secretKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T | { error?: { message?: string } };
|
||||
if (!response.ok) {
|
||||
throw new StripeAppError(
|
||||
typeof payload === "object" && payload && "error" in payload
|
||||
? payload.error?.message ?? "Stripe request failed"
|
||||
: "Stripe request failed",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async formRequest<T>(path: string, body: Record<string, string>): Promise<T> {
|
||||
if (!this.secretKey) {
|
||||
throw new StripeAppError("Stripe is not configured", 500);
|
||||
}
|
||||
|
||||
const form = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
form.set(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.secretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T | { error?: { message?: string } };
|
||||
if (!response.ok) {
|
||||
throw new StripeAppError(
|
||||
typeof payload === "object" && payload && "error" in payload
|
||||
? payload.error?.message ?? "Stripe request failed"
|
||||
: "Stripe request failed",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
|
||||
function planIdFromMetadata(metadata: unknown): FactoryBillingPlanId | null {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
const planId = (metadata as Record<string, unknown>).planId;
|
||||
return planId === "team" || planId === "free" ? planId : null;
|
||||
}
|
||||
|
||||
function firstStripePriceId(subscription: Record<string, unknown> | null): string | null {
|
||||
if (!subscription || typeof subscription.items !== "object" || !subscription.items) {
|
||||
return null;
|
||||
}
|
||||
const data = (subscription.items as { data?: Array<Record<string, unknown>> }).data;
|
||||
const first = data?.[0];
|
||||
if (!first || typeof first.price !== "object" || !first.price) {
|
||||
return null;
|
||||
}
|
||||
return typeof (first.price as Record<string, unknown>).id === "string"
|
||||
? ((first.price as Record<string, unknown>).id as string)
|
||||
: null;
|
||||
}
|
||||
|
||||
function paymentMethodLabelFromObject(paymentMethod: unknown): string {
|
||||
if (!paymentMethod || typeof paymentMethod !== "object") {
|
||||
return "Card on file";
|
||||
}
|
||||
const card = (paymentMethod as Record<string, unknown>).card;
|
||||
if (card && typeof card === "object") {
|
||||
const brand = typeof (card as Record<string, unknown>).brand === "string" ? ((card as Record<string, unknown>).brand as string) : "Card";
|
||||
const last4 = typeof (card as Record<string, unknown>).last4 === "string" ? ((card as Record<string, unknown>).last4 as string) : "file";
|
||||
return `${capitalize(brand)} ending in ${last4}`;
|
||||
}
|
||||
return "Payment method on file";
|
||||
}
|
||||
|
||||
function stripeSubscriptionSnapshot(payload: Record<string, unknown>): StripeSubscriptionSnapshot {
|
||||
return {
|
||||
id: typeof payload.id === "string" ? payload.id : "",
|
||||
customerId: typeof payload.customer === "string" ? payload.customer : "",
|
||||
priceId: firstStripePriceId(payload),
|
||||
status: typeof payload.status === "string" ? payload.status : "active",
|
||||
cancelAtPeriodEnd: payload.cancel_at_period_end === true,
|
||||
currentPeriodEnd: typeof payload.current_period_end === "number" ? payload.current_period_end : null,
|
||||
trialEnd: typeof payload.trial_end === "number" ? payload.trial_end : null,
|
||||
defaultPaymentMethodLabel: paymentMethodLabelFromObject(payload.default_payment_method),
|
||||
};
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0 ? `${value[0]!.toUpperCase()}${value.slice(1)}` : value;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ export interface ResolveCreateFlowDecisionInput {
|
|||
explicitTitle?: string;
|
||||
explicitBranchName?: string;
|
||||
localBranches: string[];
|
||||
handoffBranches: string[];
|
||||
taskBranches: string[];
|
||||
}
|
||||
|
||||
export interface ResolveCreateFlowDecisionResult {
|
||||
|
|
@ -20,7 +20,7 @@ function firstNonEmptyLine(input: string): string {
|
|||
}
|
||||
|
||||
export function deriveFallbackTitle(task: string, explicitTitle?: string): string {
|
||||
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update handoff";
|
||||
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update task";
|
||||
const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i);
|
||||
if (explicitPrefixMatch) {
|
||||
const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase();
|
||||
|
|
@ -34,7 +34,7 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin
|
|||
.slice(0, 62)
|
||||
.trim();
|
||||
|
||||
return `${explicitTypePrefix}: ${explicitSummary || "update handoff"}`;
|
||||
return `${explicitTypePrefix}: ${explicitSummary || "update task"}`;
|
||||
}
|
||||
|
||||
const lowered = source.toLowerCase();
|
||||
|
|
@ -55,7 +55,7 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin
|
|||
.filter((token) => token.length > 0)
|
||||
.join(" ");
|
||||
|
||||
const summary = (cleaned || "update handoff").slice(0, 62).trim();
|
||||
const summary = (cleaned || "update task").slice(0, 62).trim();
|
||||
return `${typePrefix}: ${summary}`.trim();
|
||||
}
|
||||
|
||||
|
|
@ -93,16 +93,16 @@ export function resolveCreateFlowDecision(
|
|||
): ResolveCreateFlowDecisionResult {
|
||||
const explicitBranch = input.explicitBranchName?.trim();
|
||||
const title = deriveFallbackTitle(input.task, input.explicitTitle);
|
||||
const generatedBase = sanitizeBranchName(title) || "handoff";
|
||||
const generatedBase = sanitizeBranchName(title) || "task";
|
||||
|
||||
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
|
||||
|
||||
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
|
||||
const existingHandoffBranches = new Set(
|
||||
input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||
const existingTaskBranches = new Set(
|
||||
input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||
);
|
||||
const conflicts = (name: string): boolean =>
|
||||
existingBranches.has(name) || existingHandoffBranches.has(name);
|
||||
existingBranches.has(name) || existingTaskBranches.has(name);
|
||||
|
||||
if (explicitBranch && conflicts(branchBase)) {
|
||||
throw new Error(
|
||||
|
|
|
|||
248
factory/packages/backend/test/app-state.test.ts
Normal file
248
factory/packages/backend/test/app-state.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { setupTest } from "rivetkit/test";
|
||||
import { registry } from "../src/actors/index.js";
|
||||
import { workspaceKey } from "../src/actors/keys.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "../src/actors/workspace/app-shell.js";
|
||||
import { createTestRuntimeContext } from "./helpers/test-context.js";
|
||||
import { createTestDriver } from "./helpers/test-driver.js";
|
||||
|
||||
function createGithubService(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
isAppConfigured: () => true,
|
||||
isWebhookConfigured: () => false,
|
||||
verifyWebhookEvent: () => { throw new Error("GitHub webhook not configured in test"); },
|
||||
buildAuthorizeUrl: (state: string) => `https://github.example/login/oauth/authorize?state=${encodeURIComponent(state)}`,
|
||||
exchangeCode: async () => ({
|
||||
accessToken: "gho_live",
|
||||
scopes: ["read:user", "user:email", "read:org"],
|
||||
}),
|
||||
getViewer: async () => ({
|
||||
id: "1001",
|
||||
login: "nathan",
|
||||
name: "Nathan",
|
||||
email: "nathan@acme.dev",
|
||||
}),
|
||||
listOrganizations: async () => [
|
||||
{
|
||||
id: "2001",
|
||||
login: "acme",
|
||||
name: "Acme",
|
||||
},
|
||||
],
|
||||
listInstallations: async () => [
|
||||
{
|
||||
id: 3001,
|
||||
accountLogin: "acme",
|
||||
},
|
||||
],
|
||||
listUserRepositories: async () => [
|
||||
{
|
||||
fullName: "nathan/personal-site",
|
||||
cloneUrl: "https://github.com/nathan/personal-site.git",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
listInstallationRepositories: async () => [
|
||||
{
|
||||
fullName: "acme/backend",
|
||||
cloneUrl: "https://github.com/acme/backend.git",
|
||||
private: true,
|
||||
},
|
||||
{
|
||||
fullName: "acme/frontend",
|
||||
cloneUrl: "https://github.com/acme/frontend.git",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
buildInstallationUrl: async () => "https://github.example/apps/sandbox/installations/new",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStripeService(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
isConfigured: () => false,
|
||||
createCustomer: async () => ({ id: "cus_test" }),
|
||||
createCheckoutSession: async () => ({ id: "cs_test", url: "https://billing.example/checkout/cs_test" }),
|
||||
retrieveCheckoutCompletion: async () => ({
|
||||
customerId: "cus_test",
|
||||
subscriptionId: "sub_test",
|
||||
planId: "team" as const,
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
retrieveSubscription: async () => ({
|
||||
id: "sub_test",
|
||||
customerId: "cus_test",
|
||||
priceId: "price_team",
|
||||
status: "active",
|
||||
cancelAtPeriodEnd: false,
|
||||
currentPeriodEnd: 1741564800,
|
||||
trialEnd: null,
|
||||
defaultPaymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
createPortalSession: async () => ({ url: "https://billing.example/portal" }),
|
||||
updateSubscriptionCancellation: async (_subscriptionId: string, cancelAtPeriodEnd: boolean) => ({
|
||||
id: "sub_test",
|
||||
customerId: "cus_test",
|
||||
priceId: "price_team",
|
||||
status: "active",
|
||||
cancelAtPeriodEnd,
|
||||
currentPeriodEnd: 1741564800,
|
||||
trialEnd: null,
|
||||
defaultPaymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
verifyWebhookEvent: (payload: string) => JSON.parse(payload),
|
||||
planIdForPriceId: (priceId: string) => (priceId === "price_team" ? ("team" as const) : null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAppWorkspace(client: any) {
|
||||
return await client.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
describe("app shell actors", () => {
|
||||
it("restores a GitHub session and imports repos into actor-owned workspace state", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService(),
|
||||
stripe: createStripeService(),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
const callback = await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
expect(callback.redirectTo).toContain("factorySession=");
|
||||
|
||||
const snapshot = await app.selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
});
|
||||
|
||||
expect(snapshot.auth.status).toBe("signed_in");
|
||||
expect(snapshot.activeOrganizationId).toBe("acme");
|
||||
expect(snapshot.users[0]?.githubLogin).toBe("nathan");
|
||||
expect(snapshot.organizations.map((organization: any) => organization.id)).toEqual(["personal-nathan", "acme"]);
|
||||
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.github.syncStatus).toBe("synced");
|
||||
expect(acme.github.installationStatus).toBe("connected");
|
||||
expect(acme.repoCatalog).toEqual(["acme/backend", "acme/frontend"]);
|
||||
|
||||
const orgWorkspace = await client.workspace.getOrCreate(workspaceKey("acme"), {
|
||||
createWithInput: "acme",
|
||||
});
|
||||
const repos = await orgWorkspace.listRepos({ workspaceId: "acme" });
|
||||
expect(repos.map((repo: any) => repo.remoteUrl).sort()).toEqual([
|
||||
"https://github.com/acme/backend.git",
|
||||
"https://github.com/acme/frontend.git",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps install-required orgs in actor state when the GitHub App installation is missing", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService({
|
||||
listInstallations: async () => [],
|
||||
}),
|
||||
stripe: createStripeService(),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
|
||||
const snapshot = await app.triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
});
|
||||
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.github.installationStatus).toBe("install_required");
|
||||
expect(acme.github.syncStatus).toBe("error");
|
||||
expect(acme.github.lastSyncLabel).toContain("installation required");
|
||||
});
|
||||
|
||||
it("maps Stripe checkout and invoice events back into organization actors", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService({
|
||||
listInstallationRepositories: async () => [],
|
||||
}),
|
||||
stripe: createStripeService({
|
||||
isConfigured: () => true,
|
||||
}),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
|
||||
const checkout = await app.createAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
planId: "team",
|
||||
});
|
||||
expect(checkout.url).toBe("https://billing.example/checkout/cs_test");
|
||||
|
||||
const completion = await app.finalizeAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
checkoutSessionId: "cs_test",
|
||||
});
|
||||
expect(completion.redirectTo).toContain("/organizations/acme/billing");
|
||||
|
||||
await app.handleAppStripeWebhook({
|
||||
payload: JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "invoice.paid",
|
||||
data: {
|
||||
object: {
|
||||
id: "in_1",
|
||||
customer: "cus_test",
|
||||
number: "0001",
|
||||
amount_paid: 24000,
|
||||
created: 1741564800,
|
||||
},
|
||||
},
|
||||
}),
|
||||
signatureHeader: "sig",
|
||||
});
|
||||
|
||||
const snapshot = await app.getAppSnapshot({ sessionId });
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.billing.planId).toBe("team");
|
||||
expect(acme.billing.status).toBe("active");
|
||||
expect(acme.billing.paymentMethodLabel).toBe("Visa ending in 4242");
|
||||
expect(acme.billing.invoices[0]).toMatchObject({
|
||||
id: "in_1",
|
||||
amountUsd: 240,
|
||||
status: "paid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { applyDevelopmentEnvDefaults, loadDevelopmentEnvFiles } from "../src/config/env.js";
|
||||
|
|
@ -10,6 +10,7 @@ const ENV_KEYS = [
|
|||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"GITHUB_REDIRECT_URI",
|
||||
"GITHUB_APP_PRIVATE_KEY",
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_ENV = new Map<string, string | undefined>(
|
||||
|
|
@ -45,6 +46,21 @@ describe("development env loading", () => {
|
|||
expect(process.env.APP_URL).toBe("http://localhost:4999");
|
||||
});
|
||||
|
||||
test("walks parent directories to find repo-level development env files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
const nested = join(dir, "factory", "packages", "backend");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(dir, ".env.development.local"), "APP_URL=http://localhost:4888\n", "utf8");
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.APP_URL;
|
||||
|
||||
const loaded = loadDevelopmentEnvFiles(nested);
|
||||
|
||||
expect(loaded).toContain(join(dir, ".env.development.local"));
|
||||
expect(process.env.APP_URL).toBe("http://localhost:4888");
|
||||
});
|
||||
|
||||
test("skips dotenv files outside development", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
writeFileSync(join(dir, ".env.development"), "APP_URL=http://localhost:4999\n", "utf8");
|
||||
|
|
@ -72,4 +88,20 @@ describe("development env loading", () => {
|
|||
expect(process.env.BETTER_AUTH_SECRET).toBe("sandbox-agent-factory-development-only-change-me");
|
||||
expect(process.env.GITHUB_REDIRECT_URI).toBe("http://localhost:4173/api/rivet/app/auth/github/callback");
|
||||
});
|
||||
|
||||
test("decodes escaped newlines for quoted env values", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
writeFileSync(
|
||||
join(dir, ".env.development"),
|
||||
'GITHUB_APP_PRIVATE_KEY="line-1\\nline-2\\n"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
|
||||
loadDevelopmentEnvFiles(dir);
|
||||
|
||||
expect(process.env.GITHUB_APP_PRIVATE_KEY).toBe("line-1\nline-2\n");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("create flow decision", () => {
|
|||
const resolved = resolveCreateFlowDecision({
|
||||
task: "Add auth",
|
||||
localBranches: ["feat-add-auth"],
|
||||
handoffBranches: ["feat-add-auth-2"]
|
||||
taskBranches: ["feat-add-auth-2"]
|
||||
});
|
||||
|
||||
expect(resolved.title).toBe("feat: Add auth");
|
||||
|
|
@ -38,7 +38,7 @@ describe("create flow decision", () => {
|
|||
task: "new task",
|
||||
explicitBranchName: "existing-branch",
|
||||
localBranches: ["existing-branch"],
|
||||
handoffBranches: []
|
||||
taskBranches: []
|
||||
})
|
||||
).toThrow("already exists");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-1",
|
||||
taskId: "task-1",
|
||||
});
|
||||
|
||||
expect(client.createSandboxCalls).toHaveLength(1);
|
||||
|
|
@ -94,7 +94,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
|
||||
expect(handle.metadata.snapshot).toBe("snapshot-factory");
|
||||
expect(handle.metadata.image).toBe("ubuntu:24.04");
|
||||
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/handoff-1/repo");
|
||||
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/task-1/repo");
|
||||
expect(client.executedCommands.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-timeout",
|
||||
taskId: "task-timeout",
|
||||
})).rejects.toThrow("daytona create sandbox timed out after 120ms");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
|||
import type { BackendDriver } from "../../src/driver.js";
|
||||
import { initActorRuntimeContext } from "../../src/actors/context.js";
|
||||
import { createProviderRegistry } from "../../src/providers/index.js";
|
||||
import { createDefaultAppShellServices, type AppShellServices } from "../../src/services/app-shell-runtime.js";
|
||||
|
||||
export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
||||
return ConfigSchema.parse({
|
||||
|
|
@ -31,10 +32,21 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|||
|
||||
export function createTestRuntimeContext(
|
||||
driver: BackendDriver,
|
||||
configOverrides?: Partial<AppConfig>
|
||||
configOverrides?: Partial<AppConfig>,
|
||||
appShellOverrides?: Partial<AppShellServices>
|
||||
): { config: AppConfig } {
|
||||
const config = createTestConfig(configOverrides);
|
||||
const providers = createProviderRegistry(config, driver);
|
||||
initActorRuntimeContext(config, providers, undefined, driver);
|
||||
initActorRuntimeContext(
|
||||
config,
|
||||
providers,
|
||||
undefined,
|
||||
driver,
|
||||
createDefaultAppShellServices({
|
||||
appUrl: appShellOverrides?.appUrl,
|
||||
github: appShellOverrides?.github,
|
||||
stripe: appShellOverrides?.stripe,
|
||||
}),
|
||||
);
|
||||
return { config };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
handoffKey,
|
||||
handoffStatusSyncKey,
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
repoBranchSyncKey,
|
||||
repoKey,
|
||||
repoPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
} from "../src/actors/keys.js";
|
||||
|
|
@ -14,13 +14,13 @@ describe("actor keys", () => {
|
|||
it("prefixes every key with workspace namespace", () => {
|
||||
const keys = [
|
||||
workspaceKey("default"),
|
||||
projectKey("default", "repo"),
|
||||
handoffKey("default", "repo", "handoff"),
|
||||
repoKey("default", "repo"),
|
||||
taskKey("default", "task"),
|
||||
sandboxInstanceKey("default", "daytona", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
|
||||
repoPrSyncKey("default", "repo"),
|
||||
repoBranchSyncKey("default", "repo"),
|
||||
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function makeConfig(): AppConfig {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/task.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
normalizeParentBranch,
|
||||
parentLookupFromStack,
|
||||
sortBranchesForOverview,
|
||||
} from "../src/actors/project/stack-model.js";
|
||||
} from "../src/actors/repo/stack-model.js";
|
||||
|
||||
describe("stack-model", () => {
|
||||
it("normalizes self-parent references to null", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/handoff/workbench.js";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js";
|
||||
|
||||
describe("workbench unread status transitions", () => {
|
||||
it("marks unread when a running session first becomes idle", () => {
|
||||
|
|
|
|||
|
|
@ -30,18 +30,18 @@ async function waitForWorkspaceRows(
|
|||
expectedCount: number
|
||||
) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const rows = await ws.listHandoffs({ workspaceId });
|
||||
const rows = await ws.listTasks({ workspaceId });
|
||||
if (rows.length >= expectedCount) {
|
||||
return rows;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
return ws.listHandoffs({ workspaceId });
|
||||
return ws.listTasks({ workspaceId });
|
||||
}
|
||||
|
||||
describe("workspace isolation", () => {
|
||||
it.skipIf(!runActorIntegration)(
|
||||
"keeps handoff lists isolated by workspace",
|
||||
"keeps task lists isolated by workspace",
|
||||
async (t) => {
|
||||
const testDriver = createTestDriver();
|
||||
createTestRuntimeContext(testDriver);
|
||||
|
|
@ -58,7 +58,7 @@ describe("workspace isolation", () => {
|
|||
const repoA = await wsA.addRepo({ workspaceId: "alpha", remoteUrl: repoPath });
|
||||
const repoB = await wsB.addRepo({ workspaceId: "beta", remoteUrl: repoPath });
|
||||
|
||||
await wsA.createHandoff({
|
||||
await wsA.createTask({
|
||||
workspaceId: "alpha",
|
||||
repoId: repoA.repoId,
|
||||
task: "task A",
|
||||
|
|
@ -67,7 +67,7 @@ describe("workspace isolation", () => {
|
|||
explicitTitle: "A"
|
||||
});
|
||||
|
||||
await wsB.createHandoff({
|
||||
await wsB.createTask({
|
||||
workspaceId: "beta",
|
||||
repoId: repoB.repoId,
|
||||
task: "task B",
|
||||
|
|
@ -83,7 +83,7 @@ describe("workspace isolation", () => {
|
|||
expect(bRows.length).toBe(1);
|
||||
expect(aRows[0]?.workspaceId).toBe("alpha");
|
||||
expect(bRows[0]?.workspaceId).toBe("beta");
|
||||
expect(aRows[0]?.handoffId).not.toBe(bRows[0]?.handoffId);
|
||||
expect(aRows[0]?.taskId).not.toBe(bRows[0]?.taskId);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"name": "@sandbox-agent/factory-cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"hf": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@opentui/core": "^0.1.77",
|
||||
"@sandbox-agent/factory-client": "workspace:*",
|
||||
"@sandbox-agent/factory-shared": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,446 +0,0 @@
|
|||
import * as childProcess from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { checkBackendHealth } from "@sandbox-agent/factory-client";
|
||||
import type { AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import { CLI_BUILD_ID } from "../build-id.js";
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 1_500;
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const POLL_INTERVAL_MS = 150;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
||||
}
|
||||
|
||||
function sanitizeHost(host: string): string {
|
||||
return host
|
||||
.split("")
|
||||
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function backendStateDir(): string {
|
||||
const override = process.env.HF_BACKEND_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
|
||||
if (xdgDataHome) {
|
||||
return join(xdgDataHome, "sandbox-agent-factory", "backend");
|
||||
}
|
||||
|
||||
return join(homedir(), ".local", "share", "sandbox-agent-factory", "backend");
|
||||
}
|
||||
|
||||
function backendPidPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.pid`);
|
||||
}
|
||||
|
||||
function backendVersionPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.version`);
|
||||
}
|
||||
|
||||
function backendLogPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.log`);
|
||||
}
|
||||
|
||||
function readText(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPid(host: string, port: number): number | null {
|
||||
const raw = readText(backendPidPath(host, port));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
|
||||
function writePid(host: string, port: number, pid: number): void {
|
||||
const path = backendPidPath(host, port);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, String(pid), "utf8");
|
||||
}
|
||||
|
||||
function removePid(host: string, port: number): void {
|
||||
const path = backendPidPath(host, port);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
function readBackendVersion(host: string, port: number): string | null {
|
||||
return readText(backendVersionPath(host, port));
|
||||
}
|
||||
|
||||
function writeBackendVersion(host: string, port: number, buildId: string): void {
|
||||
const path = backendVersionPath(host, port);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, buildId, "utf8");
|
||||
}
|
||||
|
||||
function removeBackendVersion(host: string, port: number): void {
|
||||
const path = backendVersionPath(host, port);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
function readCliBuildId(): string {
|
||||
const override = process.env.HF_BUILD_ID?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return CLI_BUILD_ID;
|
||||
}
|
||||
|
||||
function isVersionCurrent(host: string, port: number): boolean {
|
||||
return readBackendVersion(host, port) === readCliBuildId();
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeStateFiles(host: string, port: number): void {
|
||||
removePid(host, port);
|
||||
removeBackendVersion(host, port);
|
||||
}
|
||||
|
||||
async function checkHealth(host: string, port: number): Promise<boolean> {
|
||||
return await checkBackendHealth({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: HEALTH_TIMEOUT_MS
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(host: string, port: number, timeoutMs: number, pid?: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (pid && !isProcessRunning(pid)) {
|
||||
throw new Error(`backend process ${pid} exited before becoming healthy`);
|
||||
}
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`backend did not become healthy within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForChildPid(child: childProcess.ChildProcess): Promise<number | null> {
|
||||
if (child.pid && child.pid > 0) {
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await sleep(50);
|
||||
if (child.pid && child.pid > 0) {
|
||||
return child.pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LaunchSpec {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
function resolveBunCommand(): string {
|
||||
const override = process.env.HF_BUN?.trim();
|
||||
if (override && (override === "bun" || existsSync(override))) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const homeBun = join(homedir(), ".bun", "bin", "bun");
|
||||
if (existsSync(homeBun)) {
|
||||
return homeBun;
|
||||
}
|
||||
|
||||
return "bun";
|
||||
}
|
||||
|
||||
function resolveLaunchSpec(host: string, port: number): LaunchSpec {
|
||||
const repoRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const backendEntry = resolve(fileURLToPath(new URL("../../backend/dist/index.js", import.meta.url)));
|
||||
|
||||
if (existsSync(backendEntry)) {
|
||||
return {
|
||||
command: resolveBunCommand(),
|
||||
args: [backendEntry, "start", "--host", host, "--port", String(port)],
|
||||
cwd: repoRoot
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"--filter",
|
||||
"@sandbox-agent/factory-backend",
|
||||
"exec",
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"start",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
String(port)
|
||||
],
|
||||
cwd: repoRoot
|
||||
};
|
||||
}
|
||||
|
||||
async function startBackend(host: string, port: number): Promise<void> {
|
||||
if (await checkHealth(host, port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPid = readPid(host, port);
|
||||
if (existingPid && isProcessRunning(existingPid)) {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, existingPid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingPid) {
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
const logPath = backendLogPath(host, port);
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
const fd = openSync(logPath, "a");
|
||||
|
||||
const launch = resolveLaunchSpec(host, port);
|
||||
const child = childProcess.spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
env: process.env
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`failed to launch backend: ${String(error)}`);
|
||||
});
|
||||
|
||||
child.unref();
|
||||
closeSync(fd);
|
||||
|
||||
const pid = await waitForChildPid(child);
|
||||
|
||||
writeBackendVersion(host, port, readCliBuildId());
|
||||
if (pid) {
|
||||
writePid(host, port, pid);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, pid ?? undefined);
|
||||
} catch (error) {
|
||||
if (pid) {
|
||||
removeStateFiles(host, port);
|
||||
} else {
|
||||
removeBackendVersion(host, port);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function trySignal(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function findProcessOnPort(port: number): number | null {
|
||||
try {
|
||||
const out = childProcess
|
||||
.execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
})
|
||||
.trim();
|
||||
|
||||
const pidRaw = out.split("\n")[0]?.trim();
|
||||
if (!pidRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(pidRaw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopBackend(host: string, port: number): Promise<void> {
|
||||
let pid = readPid(host, port);
|
||||
|
||||
if (!pid) {
|
||||
if (!(await checkHealth(host, port))) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
|
||||
pid = findProcessOnPort(port);
|
||||
if (!pid) {
|
||||
throw new Error(`backend is healthy at ${host}:${port} but no PID could be resolved`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
|
||||
trySignal(pid, "SIGTERM");
|
||||
|
||||
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
trySignal(pid, "SIGKILL");
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
export interface BackendStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
version: string | null;
|
||||
versionCurrent: boolean;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export async function getBackendStatus(host: string, port: number): Promise<BackendStatus> {
|
||||
const logPath = backendLogPath(host, port);
|
||||
const pid = readPid(host, port);
|
||||
|
||||
if (pid) {
|
||||
if (isProcessRunning(pid)) {
|
||||
return {
|
||||
running: true,
|
||||
pid,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath
|
||||
};
|
||||
}
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
return {
|
||||
running: true,
|
||||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
running: false,
|
||||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: false,
|
||||
logPath
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureBackendRunning(config: AppConfig): Promise<void> {
|
||||
const host = config.backend.host;
|
||||
const port = config.backend.port;
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
if (!isVersionCurrent(host, port)) {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = readPid(host, port);
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
try {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, pid);
|
||||
if (!isVersionCurrent(host, port)) {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pid) {
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
await startBackend(host, port);
|
||||
}
|
||||
|
||||
export function parseBackendPort(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid backend port: ${value}`);
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
declare const __HF_BUILD_ID__: string | undefined;
|
||||
|
||||
export const CLI_BUILD_ID =
|
||||
typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0
|
||||
? __HF_BUILD_ID__.trim()
|
||||
: "dev";
|
||||
|
||||
|
|
@ -1,754 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import {
|
||||
readBackendMetadata,
|
||||
createBackendClientFromConfig,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus,
|
||||
summarizeHandoffs
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import {
|
||||
ensureBackendRunning,
|
||||
getBackendStatus,
|
||||
parseBackendPort,
|
||||
stopBackend
|
||||
} from "./backend/manager.js";
|
||||
import { openEditorForTask } from "./task-editor.js";
|
||||
import { spawnCreateTmuxWindow } from "./tmux.js";
|
||||
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
|
||||
|
||||
async function ensureBunRuntime(): Promise<void> {
|
||||
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred = process.env.HF_BUN?.trim();
|
||||
const candidates = [
|
||||
preferred,
|
||||
`${homedir()}/.bun/bin/bun`,
|
||||
"bun"
|
||||
].filter((item): item is string => Boolean(item && item.length > 0));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const command = candidate;
|
||||
const canExec = command === "bun" || existsSync(command);
|
||||
if (!canExec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = child.status ?? 1;
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
throw new Error("hf requires Bun runtime. Set HF_BUN or install Bun at ~/.bun/bin/bun.");
|
||||
}
|
||||
|
||||
async function runTuiCommand(config: ReturnType<typeof loadConfig>, workspaceId: string): Promise<void> {
|
||||
const mod = await import("./tui.js");
|
||||
await mod.runTui(config, workspaceId);
|
||||
}
|
||||
|
||||
function readOption(args: string[], flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx < 0) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], flag: string): boolean {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function parseIntOption(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
label: string
|
||||
): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function positionals(args: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const item = args[i];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.startsWith("--")) {
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf backend start [--host HOST] [--port PORT]
|
||||
hf backend stop [--host HOST] [--port PORT]
|
||||
hf backend status
|
||||
hf backend inspect
|
||||
hf status [--workspace WS] [--json]
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json]
|
||||
hf workspace use <name>
|
||||
hf tui [--workspace WS]
|
||||
|
||||
hf create [task] [--workspace WS] --repo <git-remote> [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH]
|
||||
hf list [--workspace WS] [--format table|json] [--full]
|
||||
hf switch [handoff-id | -] [--workspace WS]
|
||||
hf attach <handoff-id> [--workspace WS]
|
||||
hf merge <handoff-id> [--workspace WS]
|
||||
hf archive <handoff-id> [--workspace WS]
|
||||
hf push <handoff-id> [--workspace WS]
|
||||
hf sync <handoff-id> [--workspace WS]
|
||||
hf kill <handoff-id> [--workspace WS] [--delete-branch] [--abandon]
|
||||
hf prune [--workspace WS] [--dry-run] [--yes]
|
||||
hf statusline [--workspace WS] [--format table|claude-code]
|
||||
hf db path
|
||||
hf db nuke
|
||||
|
||||
Tips:
|
||||
hf status --help Show status output format and examples
|
||||
hf history --help Show history output format and examples
|
||||
hf switch - Switch to most recently updated handoff
|
||||
`);
|
||||
}
|
||||
|
||||
function printStatusUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf status [--workspace WS] [--json]
|
||||
|
||||
Text Output:
|
||||
workspace=<workspace-id>
|
||||
backend running=<true|false> pid=<pid|unknown> version=<version|unknown>
|
||||
handoffs total=<number>
|
||||
status queued=<n> running=<n> idle=<n> archived=<n> killed=<n> error=<n>
|
||||
providers <provider-id>=<count> ...
|
||||
providers -
|
||||
|
||||
JSON Output:
|
||||
{
|
||||
"workspaceId": "default",
|
||||
"backend": { ...backend status object... },
|
||||
"handoffs": {
|
||||
"total": 4,
|
||||
"byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 },
|
||||
"byProvider": { "daytona": 4 }
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
function printHistoryUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json]
|
||||
|
||||
Text Output:
|
||||
<iso8601>\t<event-kind>\t<branch|handoff|repo|->\t<payload-json>
|
||||
<iso8601>\t<event-kind>\t<branch|handoff|repo|->\t<payload-json...>
|
||||
no events
|
||||
|
||||
Notes:
|
||||
- payload is truncated to 120 characters in text mode.
|
||||
- --limit defaults to 20.
|
||||
|
||||
JSON Output:
|
||||
[
|
||||
{
|
||||
"id": "...",
|
||||
"workspaceId": "default",
|
||||
"kind": "handoff.created",
|
||||
"handoffId": "...",
|
||||
"repoId": "...",
|
||||
"branchName": "feature/foo",
|
||||
"payloadJson": "{\\"providerId\\":\\"daytona\\"}",
|
||||
"createdAt": 1770607522229
|
||||
}
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
async function handleBackend(args: string[]): Promise<void> {
|
||||
const sub = args[0] ?? "start";
|
||||
const config = loadConfig();
|
||||
const host = readOption(args, "--host") ?? config.backend.host;
|
||||
const port = parseBackendPort(readOption(args, "--port"), config.backend.port);
|
||||
const backendConfig = {
|
||||
...config,
|
||||
backend: {
|
||||
...config.backend,
|
||||
host,
|
||||
port
|
||||
}
|
||||
};
|
||||
|
||||
if (sub === "start") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const status = await getBackendStatus(host, port);
|
||||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "stop") {
|
||||
await stopBackend(host, port);
|
||||
console.log(`running=false host=${host} port=${port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "status") {
|
||||
const status = await getBackendStatus(host, port);
|
||||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(
|
||||
`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "inspect") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const metadata = await readBackendMetadata({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: 4_000
|
||||
});
|
||||
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
|
||||
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" });
|
||||
console.log(inspectorUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown backend subcommand: ${sub}`);
|
||||
}
|
||||
|
||||
async function handleWorkspace(args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub !== "use") {
|
||||
throw new Error("Usage: hf workspace use <name>");
|
||||
}
|
||||
|
||||
const name = args[1];
|
||||
if (!name) {
|
||||
throw new Error("Missing workspace name");
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
config.workspace.default = name;
|
||||
saveConfig(config);
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
try {
|
||||
await client.useWorkspace(name);
|
||||
} catch {
|
||||
// Backend may not be running yet. Config is already updated.
|
||||
}
|
||||
|
||||
console.log(`workspace=${name}`);
|
||||
}
|
||||
|
||||
async function handleList(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const format = readOption(args, "--format") ?? "table";
|
||||
const full = hasFlag(args, "--full");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no handoffs");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
let line = `${row.handoffId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`;
|
||||
if (full) {
|
||||
const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task;
|
||||
line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`;
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush(args: string[]): Promise<void> {
|
||||
const handoffId = positionals(args)[0];
|
||||
if (!handoffId) {
|
||||
throw new Error("Missing handoff id for push");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, handoffId, "push");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handleSync(args: string[]): Promise<void> {
|
||||
const handoffId = positionals(args)[0];
|
||||
if (!handoffId) {
|
||||
throw new Error("Missing handoff id for sync");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, handoffId, "sync");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handleKill(args: string[]): Promise<void> {
|
||||
const handoffId = positionals(args)[0];
|
||||
if (!handoffId) {
|
||||
throw new Error("Missing handoff id for kill");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const deleteBranch = hasFlag(args, "--delete-branch");
|
||||
const abandon = hasFlag(args, "--abandon");
|
||||
|
||||
if (deleteBranch) {
|
||||
console.log("info: --delete-branch flag set, branch will be deleted after kill");
|
||||
}
|
||||
if (abandon) {
|
||||
console.log("info: --abandon flag set, Graphite abandon will be attempted");
|
||||
}
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, handoffId, "kill");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handlePrune(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const dryRun = hasFlag(args, "--dry-run");
|
||||
const yes = hasFlag(args, "--yes");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed");
|
||||
|
||||
if (prunable.length === 0) {
|
||||
console.log("nothing to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of prunable) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
console.log(`${dryRun ? "[dry-run] " : ""}${row.handoffId}\t${row.branchName}\t${row.status}\t${age}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n${prunable.length} handoff(s) would be pruned`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
console.log("\nnot yet implemented: auto-pruning requires confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${prunable.length} handoff(s) would be pruned (pruning not yet implemented)`);
|
||||
}
|
||||
|
||||
async function handleStatusline(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const format = readOption(args, "--format") ?? "table";
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const summary = summarizeHandoffs(rows);
|
||||
const running = summary.byStatus.running;
|
||||
const idle = summary.byStatus.idle;
|
||||
const errorCount = summary.byStatus.error;
|
||||
|
||||
if (format === "claude-code") {
|
||||
console.log(`hf:${running}R/${idle}I/${errorCount}E`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`running=${running} idle=${idle} error=${errorCount}`);
|
||||
}
|
||||
|
||||
async function handleDb(args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub === "path") {
|
||||
const config = loadConfig();
|
||||
const dbPath = config.backend.dbPath.replace(/^~/, homedir());
|
||||
console.log(dbPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "nuke") {
|
||||
console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Usage: hf db path | hf db nuke");
|
||||
}
|
||||
|
||||
async function waitForHandoffReady(
|
||||
client: ReturnType<typeof createBackendClientFromConfig>,
|
||||
workspaceId: string,
|
||||
handoffId: string,
|
||||
timeoutMs: number
|
||||
): Promise<HandoffRecord> {
|
||||
const start = Date.now();
|
||||
let delayMs = 250;
|
||||
|
||||
for (;;) {
|
||||
const record = await client.getHandoff(workspaceId, handoffId);
|
||||
const hasName = Boolean(record.branchName && record.title);
|
||||
const hasSandbox = Boolean(record.activeSandboxId);
|
||||
|
||||
if (record.status === "error") {
|
||||
throw new Error(`handoff entered error state while provisioning: ${handoffId}`);
|
||||
}
|
||||
if (hasName && hasSandbox) {
|
||||
return record;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for handoff provisioning: ${handoffId}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
delayMs = Math.min(Math.round(delayMs * 1.5), 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
|
||||
const repoRemote = readOption(args, "--repo");
|
||||
if (!repoRemote) {
|
||||
throw new Error("Missing required --repo <git-remote>");
|
||||
}
|
||||
const explicitBranchName = readOption(args, "--name") ?? readOption(args, "--branch");
|
||||
const explicitTitle = readOption(args, "--title");
|
||||
|
||||
const agentRaw = readOption(args, "--agent");
|
||||
const agentType = agentRaw ? AgentTypeSchema.parse(agentRaw) : undefined;
|
||||
const onBranch = readOption(args, "--on");
|
||||
|
||||
const taskFromArgs = positionals(args).join(" ").trim();
|
||||
const task = taskFromArgs || openEditorForTask();
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
|
||||
const payload = CreateHandoffInputSchema.parse({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
task,
|
||||
explicitTitle: explicitTitle || undefined,
|
||||
explicitBranchName: explicitBranchName || undefined,
|
||||
agentType,
|
||||
onBranch
|
||||
});
|
||||
|
||||
const created = await client.createHandoff(payload);
|
||||
const handoff = await waitForHandoffReady(client, workspaceId, created.handoffId, 180_000);
|
||||
const switched = await client.switchHandoff(workspaceId, handoff.handoffId);
|
||||
const attached = await client.attachHandoff(workspaceId, handoff.handoffId);
|
||||
|
||||
console.log(`Branch: ${handoff.branchName ?? "-"}`);
|
||||
console.log(`Handoff: ${handoff.handoffId}`);
|
||||
console.log(`Provider: ${handoff.providerId}`);
|
||||
console.log(`Session: ${attached.sessionId ?? "none"}`);
|
||||
console.log(`Target: ${switched.switchTarget || attached.target}`);
|
||||
console.log(`Title: ${handoff.title ?? "-"}`);
|
||||
|
||||
const tmuxResult = spawnCreateTmuxWindow({
|
||||
branchName: handoff.branchName ?? handoff.handoffId,
|
||||
targetPath: switched.switchTarget || attached.target,
|
||||
sessionId: attached.sessionId
|
||||
});
|
||||
|
||||
if (tmuxResult.created) {
|
||||
console.log(`Window: created (${handoff.branchName})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Run: hf switch ${handoff.handoffId}`);
|
||||
if ((switched.switchTarget || attached.target).startsWith("/")) {
|
||||
console.log(`cd ${(switched.switchTarget || attached.target)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTui(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
await runTuiCommand(config, workspaceId);
|
||||
}
|
||||
|
||||
async function handleStatus(args: string[]): Promise<void> {
|
||||
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
||||
printStatusUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const backendStatus = await getBackendStatus(config.backend.host, config.backend.port);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const summary = summarizeHandoffs(rows);
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
workspaceId,
|
||||
backend: backendStatus,
|
||||
handoffs: {
|
||||
total: summary.total,
|
||||
byStatus: summary.byStatus,
|
||||
byProvider: summary.byProvider
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`workspace=${workspaceId}`);
|
||||
console.log(
|
||||
`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`
|
||||
);
|
||||
console.log(`handoffs total=${summary.total}`);
|
||||
console.log(
|
||||
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`
|
||||
);
|
||||
const providerSummary = Object.entries(summary.byProvider)
|
||||
.map(([provider, count]) => `${provider}=${count}`)
|
||||
.join(" ");
|
||||
console.log(`providers ${providerSummary || "-"}`);
|
||||
}
|
||||
|
||||
async function handleHistory(args: string[]): Promise<void> {
|
||||
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
||||
printHistoryUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const limit = parseIntOption(readOption(args, "--limit"), 20, "limit");
|
||||
const branch = readOption(args, "--branch");
|
||||
const handoffId = readOption(args, "--handoff");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHistory({
|
||||
workspaceId,
|
||||
limit,
|
||||
branch: branch || undefined,
|
||||
handoffId: handoffId || undefined
|
||||
});
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no events");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const ts = new Date(row.createdAt).toISOString();
|
||||
const target = row.branchName || row.handoffId || row.repoId || "-";
|
||||
let payload = row.payloadJson;
|
||||
if (payload.length > 120) {
|
||||
payload = `${payload.slice(0, 117)}...`;
|
||||
}
|
||||
console.log(`${ts}\t${row.kind}\t${target}\t${payload}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchLike(cmd: string, args: string[]): Promise<void> {
|
||||
let handoffId = positionals(args)[0];
|
||||
if (!handoffId && cmd === "switch") {
|
||||
await handleTui(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handoffId) {
|
||||
throw new Error(`Missing handoff id for ${cmd}`);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
|
||||
if (cmd === "switch" && handoffId === "-") {
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const active = rows.filter((r) => {
|
||||
const group = groupHandoffStatus(r.status);
|
||||
return group === "running" || group === "idle" || group === "queued";
|
||||
});
|
||||
const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const target = sorted[0];
|
||||
if (!target) {
|
||||
throw new Error("No active handoffs to switch to");
|
||||
}
|
||||
handoffId = target.handoffId;
|
||||
}
|
||||
|
||||
if (cmd === "switch") {
|
||||
const result = await client.switchHandoff(workspaceId, handoffId);
|
||||
console.log(`cd ${result.switchTarget}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "attach") {
|
||||
const result = await client.attachHandoff(workspaceId, handoffId);
|
||||
console.log(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "merge" || cmd === "archive") {
|
||||
await client.runAction(workspaceId, handoffId, cmd);
|
||||
console.log("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported action: ${cmd}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensureBunRuntime();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "backend") {
|
||||
await handleBackend(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
if (!cmd || cmd.startsWith("--")) {
|
||||
await handleTui(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "workspace") {
|
||||
await handleWorkspace(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "create") {
|
||||
await handleCreate(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "list") {
|
||||
await handleList(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "tui") {
|
||||
await handleTui(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "status") {
|
||||
await handleStatus(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "history") {
|
||||
await handleHistory(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "push") {
|
||||
await handlePush(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "sync") {
|
||||
await handleSync(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "kill") {
|
||||
await handleKill(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "prune") {
|
||||
await handlePrune(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "statusline") {
|
||||
await handleStatusline(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "db") {
|
||||
await handleDb(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["switch", "attach", "merge", "archive"].includes(cmd)) {
|
||||
await handleSwitchLike(cmd, rest);
|
||||
return;
|
||||
}
|
||||
|
||||
printUsage();
|
||||
throw new Error(`Unknown command: ${cmd}`);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_EDITOR_TEMPLATE = [
|
||||
"# Enter handoff task details below.",
|
||||
"# Lines starting with # are ignored.",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
export function sanitizeEditorTask(input: string): string {
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function openEditorForTask(): string {
|
||||
const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || "vi";
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "hf-task-"));
|
||||
const taskPath = join(tempDir, "task.md");
|
||||
|
||||
try {
|
||||
writeFileSync(taskPath, DEFAULT_EDITOR_TEMPLATE, "utf8");
|
||||
const result = spawnSync(editor, [taskPath], { stdio: "inherit" });
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
throw new Error(`Editor exited with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(taskPath, "utf8");
|
||||
const task = sanitizeEditorTask(raw);
|
||||
if (!task) {
|
||||
throw new Error("Missing handoff task text");
|
||||
}
|
||||
return task;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,811 +0,0 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import * as toml from "@iarna/toml";
|
||||
import type { AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
|
||||
|
||||
export type ThemeMode = "dark" | "light";
|
||||
|
||||
export interface TuiTheme {
|
||||
background: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
header: string;
|
||||
status: string;
|
||||
highlightBg: string;
|
||||
highlightFg: string;
|
||||
selectionBorder: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
diffAdd: string;
|
||||
diffDel: string;
|
||||
diffSep: string;
|
||||
agentRunning: string;
|
||||
agentIdle: string;
|
||||
agentNone: string;
|
||||
agentError: string;
|
||||
prUnpushed: string;
|
||||
author: string;
|
||||
ciRunning: string;
|
||||
ciPass: string;
|
||||
ciFail: string;
|
||||
ciNone: string;
|
||||
reviewApproved: string;
|
||||
reviewChanges: string;
|
||||
reviewPending: string;
|
||||
reviewNone: string;
|
||||
}
|
||||
|
||||
export interface TuiThemeResolution {
|
||||
theme: TuiTheme;
|
||||
name: string;
|
||||
source: string;
|
||||
mode: ThemeMode;
|
||||
}
|
||||
|
||||
interface ThemeCandidate {
|
||||
theme: TuiTheme;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type ConfigLike = AppConfig & { theme?: string };
|
||||
|
||||
const DEFAULT_THEME: TuiTheme = {
|
||||
background: "#282828",
|
||||
text: "#ffffff",
|
||||
muted: "#6b7280",
|
||||
header: "#6b7280",
|
||||
status: "#6b7280",
|
||||
highlightBg: "#282828",
|
||||
highlightFg: "#ffffff",
|
||||
selectionBorder: "#d946ef",
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
error: "#ef4444",
|
||||
info: "#22d3ee",
|
||||
diffAdd: "#22c55e",
|
||||
diffDel: "#ef4444",
|
||||
diffSep: "#6b7280",
|
||||
agentRunning: "#22c55e",
|
||||
agentIdle: "#eab308",
|
||||
agentNone: "#6b7280",
|
||||
agentError: "#ef4444",
|
||||
prUnpushed: "#eab308",
|
||||
author: "#22d3ee",
|
||||
ciRunning: "#eab308",
|
||||
ciPass: "#22c55e",
|
||||
ciFail: "#ef4444",
|
||||
ciNone: "#6b7280",
|
||||
reviewApproved: "#22c55e",
|
||||
reviewChanges: "#ef4444",
|
||||
reviewPending: "#eab308",
|
||||
reviewNone: "#6b7280"
|
||||
};
|
||||
|
||||
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
|
||||
|
||||
export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution {
|
||||
const mode = opencodeStateThemeMode() ?? "dark";
|
||||
const configWithTheme = config as ConfigLike;
|
||||
const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : "";
|
||||
|
||||
if (override) {
|
||||
const candidate = loadFromSpec(override, [], mode, baseDir);
|
||||
if (candidate) {
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: "factory config",
|
||||
mode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir);
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
const fromState = loadOpencodeThemeFromState(mode, baseDir);
|
||||
if (fromState) {
|
||||
return fromState;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default",
|
||||
source: "default",
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
||||
for (const path of opencodeConfigPaths(baseDir)) {
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const themeValue = findOpencodeThemeValue(value);
|
||||
if (themeValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode config (${path})`,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
||||
const path = opencodeStatePath();
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spec = value.theme;
|
||||
if (typeof spec !== "string" || !spec.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir);
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode state (${path})`,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
function loadFromSpec(
|
||||
spec: string,
|
||||
searchDirs: string[],
|
||||
mode: ThemeMode,
|
||||
baseDir: string
|
||||
): ThemeCandidate | null {
|
||||
if (isDefaultThemeName(spec)) {
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default"
|
||||
};
|
||||
}
|
||||
|
||||
if (isPathLike(spec)) {
|
||||
const resolved = resolvePath(spec, baseDir);
|
||||
if (existsSync(resolved)) {
|
||||
const candidate = loadThemeFromPath(resolved, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
for (const ext of ["json", "toml"]) {
|
||||
const path = join(dir, `${spec}.${ext}`);
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = loadThemeFromPath(path, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builtIn = OPENCODE_THEME_PACK[spec];
|
||||
if (builtIn !== undefined) {
|
||||
const theme = themeFromOpencodeJson(builtIn, mode);
|
||||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: spec
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null {
|
||||
const content = safeReadText(path);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".toml")) {
|
||||
try {
|
||||
const parsed = toml.parse(content);
|
||||
const theme = themeFromAny(parsed);
|
||||
if (!theme) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
theme,
|
||||
name: themeNameFromPath(path)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const value = parseJsonWithComments(content);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opencodeTheme = themeFromOpencodeJson(value, mode);
|
||||
if (opencodeTheme) {
|
||||
return {
|
||||
theme: opencodeTheme,
|
||||
name: themeNameFromPath(path)
|
||||
};
|
||||
}
|
||||
|
||||
const paletteTheme = themeFromAny(value);
|
||||
if (!paletteTheme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: themeNameFromPath(path)
|
||||
};
|
||||
}
|
||||
|
||||
function themeNameFromPath(path: string): string {
|
||||
const base = path.split(/[\\/]/).pop() ?? path;
|
||||
if (base.endsWith(".json") || base.endsWith(".toml")) {
|
||||
return base.replace(/\.(json|toml)$/i, "");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function themeFromOpencodeValue(
|
||||
value: unknown,
|
||||
searchDirs: string[],
|
||||
mode: ThemeMode,
|
||||
baseDir: string
|
||||
): ThemeCandidate | null {
|
||||
if (typeof value === "string") {
|
||||
return loadFromSpec(value, searchDirs, mode, baseDir);
|
||||
}
|
||||
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.theme !== undefined) {
|
||||
const theme = themeFromOpencodeJson(value, mode);
|
||||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: typeof value.name === "string" ? value.name : "inline"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value);
|
||||
if (paletteTheme) {
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: typeof value.name === "string" ? value.name : "inline"
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value.name === "string") {
|
||||
const named = loadFromSpec(value.name, searchDirs, mode, baseDir);
|
||||
if (named) {
|
||||
return named;
|
||||
}
|
||||
}
|
||||
|
||||
const pathLike = value.path ?? value.file;
|
||||
if (typeof pathLike === "string") {
|
||||
const resolved = resolvePath(pathLike, baseDir);
|
||||
const candidate = loadThemeFromPath(resolved, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themeMap = value.theme;
|
||||
if (!isObject(themeMap)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defs = isObject(value.defs) ? value.defs : {};
|
||||
|
||||
const background =
|
||||
opencodeColor(themeMap, defs, mode, "background") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
||||
DEFAULT_THEME.background;
|
||||
|
||||
const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text;
|
||||
const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted;
|
||||
const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text;
|
||||
const highlightFg =
|
||||
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
||||
opencodeColor(themeMap, defs, mode, "background") ??
|
||||
DEFAULT_THEME.highlightFg;
|
||||
|
||||
const selectionBorder =
|
||||
opencodeColor(themeMap, defs, mode, "secondary") ??
|
||||
opencodeColor(themeMap, defs, mode, "accent") ??
|
||||
opencodeColor(themeMap, defs, mode, "primary") ??
|
||||
DEFAULT_THEME.selectionBorder;
|
||||
|
||||
const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success;
|
||||
const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning;
|
||||
const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error;
|
||||
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
|
||||
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
|
||||
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
|
||||
const diffSep =
|
||||
opencodeColor(themeMap, defs, mode, "diffContext") ??
|
||||
opencodeColor(themeMap, defs, mode, "diffHunkHeader") ??
|
||||
muted;
|
||||
|
||||
return {
|
||||
background,
|
||||
text,
|
||||
muted,
|
||||
header: muted,
|
||||
status: muted,
|
||||
highlightBg,
|
||||
highlightFg,
|
||||
selectionBorder,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
diffAdd,
|
||||
diffDel,
|
||||
diffSep,
|
||||
agentRunning: success,
|
||||
agentIdle: warning,
|
||||
agentNone: muted,
|
||||
agentError: error,
|
||||
prUnpushed: warning,
|
||||
author: info,
|
||||
ciRunning: warning,
|
||||
ciPass: success,
|
||||
ciFail: error,
|
||||
ciNone: muted,
|
||||
reviewApproved: success,
|
||||
reviewChanges: error,
|
||||
reviewPending: warning,
|
||||
reviewNone: muted
|
||||
};
|
||||
}
|
||||
|
||||
function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null {
|
||||
const raw = themeMap[key];
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
|
||||
}
|
||||
|
||||
function resolveOpencodeColor(
|
||||
value: unknown,
|
||||
themeMap: JsonObject,
|
||||
defs: JsonObject,
|
||||
mode: ThemeMode,
|
||||
depth: number
|
||||
): string | null {
|
||||
if (depth > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromDefs = defs[trimmed];
|
||||
if (fromDefs !== undefined) {
|
||||
return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
|
||||
const fromTheme = themeMap[trimmed];
|
||||
if (fromTheme !== undefined) {
|
||||
return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
|
||||
if (isColorLike(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
const nested = value[mode];
|
||||
if (nested !== undefined) {
|
||||
return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function themeFromAny(value: unknown): TuiTheme | null {
|
||||
const palette = extractPalette(value);
|
||||
if (!palette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pick = (keys: string[], fallback: string): string => {
|
||||
for (const key of keys) {
|
||||
const v = palette[normalizeKey(key)];
|
||||
if (v && isColorLike(v)) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background);
|
||||
const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text);
|
||||
const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted);
|
||||
const header = pick(["header", "header_text"], muted);
|
||||
const status = pick(["status", "status_text"], muted);
|
||||
const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg);
|
||||
const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text);
|
||||
const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder);
|
||||
const success = pick(["success", "green"], DEFAULT_THEME.success);
|
||||
const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning);
|
||||
const error = pick(["error", "red"], DEFAULT_THEME.error);
|
||||
const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info);
|
||||
const diffAdd = pick(["diff_add", "diff_addition", "add"], success);
|
||||
const diffDel = pick(["diff_del", "diff_deletion", "delete"], error);
|
||||
const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted);
|
||||
|
||||
return {
|
||||
background,
|
||||
text,
|
||||
muted,
|
||||
header,
|
||||
status,
|
||||
highlightBg,
|
||||
highlightFg,
|
||||
selectionBorder,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
diffAdd,
|
||||
diffDel,
|
||||
diffSep,
|
||||
agentRunning: pick(["agent_running", "running"], success),
|
||||
agentIdle: pick(["agent_idle", "idle"], warning),
|
||||
agentNone: pick(["agent_none", "none"], muted),
|
||||
agentError: pick(["agent_error", "agent_failed"], error),
|
||||
prUnpushed: pick(["pr_unpushed", "unpushed"], warning),
|
||||
author: pick(["author"], info),
|
||||
ciRunning: pick(["ci_running"], warning),
|
||||
ciPass: pick(["ci_pass", "ci_success"], success),
|
||||
ciFail: pick(["ci_fail", "ci_error"], error),
|
||||
ciNone: pick(["ci_none", "ci_unknown"], muted),
|
||||
reviewApproved: pick(["review_approved", "approved"], success),
|
||||
reviewChanges: pick(["review_changes", "changes"], error),
|
||||
reviewPending: pick(["review_pending", "pending"], warning),
|
||||
reviewNone: pick(["review_none", "review_unknown"], muted)
|
||||
};
|
||||
}
|
||||
|
||||
function extractPalette(value: unknown): Record<string, string> | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colors = isObject(value.colors) ? value.colors : undefined;
|
||||
const palette = isObject(value.palette) ? value.palette : undefined;
|
||||
const source = colors ?? palette ?? value;
|
||||
if (!isObject(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, raw] of Object.entries(source)) {
|
||||
if (typeof raw !== "string") {
|
||||
continue;
|
||||
}
|
||||
out[normalizeKey(key)] = raw;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[\-\s.]/g, "_");
|
||||
}
|
||||
|
||||
function isColorLike(value: string): boolean {
|
||||
const lower = value.trim().toLowerCase();
|
||||
if (!lower) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[a-z_\-]+$/.test(lower);
|
||||
}
|
||||
|
||||
function findOpencodeThemeValue(value: unknown): unknown {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value.theme !== undefined) {
|
||||
return value.theme;
|
||||
}
|
||||
|
||||
return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]);
|
||||
}
|
||||
|
||||
function pointer(obj: JsonObject, parts: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (!isObject(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function opencodeConfigPaths(baseDir: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
const rootish = opencodeProjectConfigPaths(baseDir);
|
||||
paths.push(...rootish);
|
||||
|
||||
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
const opencodeDir = join(configDir, "opencode");
|
||||
paths.push(join(opencodeDir, "opencode.json"));
|
||||
paths.push(join(opencodeDir, "opencode.jsonc"));
|
||||
paths.push(join(opencodeDir, "config.json"));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] {
|
||||
const dirs: string[] = [];
|
||||
|
||||
if (configDir) {
|
||||
dirs.push(join(configDir, "themes"));
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
dirs.push(join(xdgConfig, "opencode", "themes"));
|
||||
dirs.push(join(homedir(), ".opencode", "themes"));
|
||||
|
||||
dirs.push(...opencodeProjectThemeDirs(baseDir));
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function opencodeProjectConfigPaths(baseDir: string): string[] {
|
||||
const dirs = ancestorDirs(baseDir);
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
out.push(join(dir, "opencode.json"));
|
||||
out.push(join(dir, "opencode.jsonc"));
|
||||
out.push(join(dir, ".opencode", "opencode.json"));
|
||||
out.push(join(dir, ".opencode", "opencode.jsonc"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function opencodeProjectThemeDirs(baseDir: string): string[] {
|
||||
const dirs = ancestorDirs(baseDir);
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
out.push(join(dir, ".opencode", "themes"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ancestorDirs(start: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = resolve(start);
|
||||
|
||||
while (true) {
|
||||
out.push(current);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function opencodeStatePath(): string | null {
|
||||
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
||||
return join(stateHome, "opencode", "kv.json");
|
||||
}
|
||||
|
||||
function opencodeStateThemeMode(): ThemeMode | null {
|
||||
const path = opencodeStatePath();
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = value.theme_mode;
|
||||
if (typeof mode !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = mode.toLowerCase();
|
||||
if (lower === "dark" || lower === "light") {
|
||||
return lower;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonWithComments(content: string): unknown {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stripJsoncComments(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonWithComments(path: string): unknown {
|
||||
const content = safeReadText(path);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return parseJsonWithComments(content);
|
||||
}
|
||||
|
||||
function stripJsoncComments(input: string): string {
|
||||
let output = "";
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
if (inString) {
|
||||
output += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
output += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && input[i + 1] === "/") {
|
||||
i += 2;
|
||||
while (i < input.length && input[i] !== "\n") {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && input[i + 1] === "*") {
|
||||
i += 2;
|
||||
while (i < input.length) {
|
||||
if (input[i] === "*" && input[i + 1] === "/") {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
output += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function safeReadText(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(path: string, baseDir: string): string {
|
||||
if (path.startsWith("~/")) {
|
||||
return join(homedir(), path.slice(2));
|
||||
}
|
||||
if (isAbsolute(path)) {
|
||||
return path;
|
||||
}
|
||||
return resolve(baseDir, path);
|
||||
}
|
||||
|
||||
function isPathLike(spec: string): boolean {
|
||||
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml");
|
||||
}
|
||||
|
||||
function isDefaultThemeName(spec: string): boolean {
|
||||
const lower = spec.toLowerCase();
|
||||
return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system";
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +0,0 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const SYMBOL_RUNNING = "▶";
|
||||
const SYMBOL_IDLE = "✓";
|
||||
const DEFAULT_OPENCODE_ENDPOINT = "http://127.0.0.1:4097/opencode";
|
||||
|
||||
export interface TmuxWindowMatch {
|
||||
target: string;
|
||||
windowName: string;
|
||||
}
|
||||
|
||||
export interface SpawnCreateTmuxWindowInput {
|
||||
branchName: string;
|
||||
targetPath: string;
|
||||
sessionId?: string | null;
|
||||
opencodeEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface SpawnCreateTmuxWindowResult {
|
||||
created: boolean;
|
||||
reason:
|
||||
| "created"
|
||||
| "not-in-tmux"
|
||||
| "not-local-path"
|
||||
| "window-exists"
|
||||
| "tmux-new-window-failed";
|
||||
}
|
||||
|
||||
function isTmuxSession(): boolean {
|
||||
return Boolean(process.env.TMUX);
|
||||
}
|
||||
|
||||
function isAbsoluteLocalPath(path: string): boolean {
|
||||
return path.startsWith("/");
|
||||
}
|
||||
|
||||
function runTmux(args: string[]): boolean {
|
||||
const result = spawnSync("tmux", args, { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function opencodeExistsOnPath(): boolean {
|
||||
const probe = spawnSync("which", ["opencode"], { stdio: "ignore" });
|
||||
return !probe.error && probe.status === 0;
|
||||
}
|
||||
|
||||
function resolveOpencodeBinary(): string {
|
||||
const envOverride = process.env.HF_OPENCODE_BIN?.trim();
|
||||
if (envOverride) {
|
||||
return envOverride;
|
||||
}
|
||||
|
||||
if (opencodeExistsOnPath()) {
|
||||
return "opencode";
|
||||
}
|
||||
|
||||
const bundledCandidates = [
|
||||
`${homedir()}/.local/share/sandbox-agent/bin/opencode`,
|
||||
`${homedir()}/.opencode/bin/opencode`
|
||||
];
|
||||
|
||||
for (const candidate of bundledCandidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "opencode";
|
||||
}
|
||||
|
||||
function attachCommand(sessionId: string, targetPath: string, endpoint: string): string {
|
||||
const opencode = resolveOpencodeBinary();
|
||||
return [
|
||||
shellEscape(opencode),
|
||||
"attach",
|
||||
shellEscape(endpoint),
|
||||
"--session",
|
||||
shellEscape(sessionId),
|
||||
"--dir",
|
||||
shellEscape(targetPath)
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function stripStatusPrefix(windowName: string): string {
|
||||
return windowName
|
||||
.trimStart()
|
||||
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
|
||||
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
|
||||
const output = spawnSync(
|
||||
"tmux",
|
||||
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
|
||||
if (output.error || output.status !== 0 || !output.stdout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
const matches: TmuxWindowMatch[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(":", 3);
|
||||
if (parts.length !== 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionName = parts[0] ?? "";
|
||||
const windowId = parts[1] ?? "";
|
||||
const windowName = parts[2] ?? "";
|
||||
const clean = stripStatusPrefix(windowName);
|
||||
if (clean !== branchName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
target: `${sessionName}:${windowId}`,
|
||||
windowName
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function spawnCreateTmuxWindow(
|
||||
input: SpawnCreateTmuxWindowInput
|
||||
): SpawnCreateTmuxWindowResult {
|
||||
if (!isTmuxSession()) {
|
||||
return { created: false, reason: "not-in-tmux" };
|
||||
}
|
||||
|
||||
if (!isAbsoluteLocalPath(input.targetPath)) {
|
||||
return { created: false, reason: "not-local-path" };
|
||||
}
|
||||
|
||||
if (findTmuxWindowsByBranch(input.branchName).length > 0) {
|
||||
return { created: false, reason: "window-exists" };
|
||||
}
|
||||
|
||||
const windowName = input.sessionId ? `${SYMBOL_RUNNING} ${input.branchName}` : input.branchName;
|
||||
const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync(
|
||||
"tmux",
|
||||
[
|
||||
"new-window",
|
||||
"-d",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{window_id}",
|
||||
"-n",
|
||||
windowName,
|
||||
"-c",
|
||||
input.targetPath
|
||||
],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
} catch {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
||||
const windowId = output.trim();
|
||||
if (!windowId) {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
||||
if (input.sessionId) {
|
||||
const leftPane = `${windowId}.0`;
|
||||
|
||||
// Split left pane horizontally → creates right pane; capture its pane ID
|
||||
let rightPane: string;
|
||||
try {
|
||||
rightPane = execFileSync(
|
||||
"tmux",
|
||||
["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
||||
if (!rightPane) {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
||||
// Split right pane vertically → top-right (rightPane) + bottom-right (new)
|
||||
runTmux(["split-window", "-v", "-t", rightPane, "-c", input.targetPath]);
|
||||
|
||||
// Left pane 60% width, top-right pane 70% height
|
||||
runTmux(["resize-pane", "-t", leftPane, "-x", "60%"]);
|
||||
runTmux(["resize-pane", "-t", rightPane, "-y", "70%"]);
|
||||
|
||||
// Editor in left pane, agent attach in top-right pane
|
||||
runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]);
|
||||
runTmux([
|
||||
"send-keys",
|
||||
"-t",
|
||||
rightPane,
|
||||
attachCommand(input.sessionId, input.targetPath, endpoint),
|
||||
"Enter"
|
||||
]);
|
||||
runTmux(["select-pane", "-t", rightPane]);
|
||||
}
|
||||
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
|
@ -1,640 +0,0 @@
|
|||
import type { AppConfig, HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
createBackendClientFromConfig,
|
||||
filterHandoffs,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import { CLI_BUILD_ID } from "./build-id.js";
|
||||
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
||||
|
||||
interface KeyEventLike {
|
||||
name?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
const HELP_LINES = [
|
||||
"Shortcuts",
|
||||
"Ctrl-H toggle cheatsheet",
|
||||
"Enter switch to branch",
|
||||
"Ctrl-A attach to session",
|
||||
"Ctrl-O open PR in browser",
|
||||
"Ctrl-X archive branch / close PR",
|
||||
"Ctrl-Y merge highlighted PR",
|
||||
"Ctrl-S sync handoff with remote",
|
||||
"Ctrl-N / Down next row",
|
||||
"Ctrl-P / Up previous row",
|
||||
"Backspace delete filter",
|
||||
"Type filter by branch/PR/author",
|
||||
"Esc / Ctrl-C cancel",
|
||||
"",
|
||||
"Legend",
|
||||
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued"
|
||||
];
|
||||
|
||||
const COLUMN_WIDTHS = {
|
||||
diff: 10,
|
||||
agent: 5,
|
||||
pr: 6,
|
||||
author: 10,
|
||||
ci: 7,
|
||||
review: 8,
|
||||
age: 5
|
||||
} as const;
|
||||
|
||||
interface DisplayRow {
|
||||
name: string;
|
||||
diff: string;
|
||||
agent: string;
|
||||
pr: string;
|
||||
author: string;
|
||||
ci: string;
|
||||
review: string;
|
||||
age: string;
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function pad(input: string, width: number): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
const chars = Array.from(input);
|
||||
const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}…` : input;
|
||||
return text.padEnd(width, " ");
|
||||
}
|
||||
|
||||
function truncateToLen(input: string, maxLen: number): string {
|
||||
if (maxLen <= 0) {
|
||||
return "";
|
||||
}
|
||||
return Array.from(input).slice(0, maxLen).join("");
|
||||
}
|
||||
|
||||
function fitLine(input: string, width: number): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
const clipped = truncateToLen(input, width);
|
||||
const len = Array.from(clipped).length;
|
||||
if (len >= width) {
|
||||
return clipped;
|
||||
}
|
||||
return `${clipped}${" ".repeat(width - len)}`;
|
||||
}
|
||||
|
||||
function overlayLine(base: string, overlay: string, startCol: number, width: number): string {
|
||||
const out = Array.from(fitLine(base, width));
|
||||
const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol)));
|
||||
for (let i = 0; i < src.length; i += 1) {
|
||||
const col = startCol + i;
|
||||
if (col >= 0 && col < out.length) {
|
||||
out[col] = src[i] ?? " ";
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function buildFooterLine(width: number, segments: string[], right: string): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rightLen = Array.from(right).length;
|
||||
if (width <= rightLen + 1) {
|
||||
return truncateToLen(right, width);
|
||||
}
|
||||
|
||||
const leftMax = width - rightLen - 1;
|
||||
let used = 0;
|
||||
let left = "";
|
||||
let first = true;
|
||||
|
||||
for (const segment of segments) {
|
||||
const chunk = first ? segment : ` | ${segment}`;
|
||||
const clipped = truncateToLen(chunk, leftMax - used);
|
||||
if (!clipped) {
|
||||
break;
|
||||
}
|
||||
left += clipped;
|
||||
used += Array.from(clipped).length;
|
||||
first = false;
|
||||
if (used >= leftMax) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const padding = " ".repeat(Math.max(0, leftMax - used) + 1);
|
||||
return `${left}${padding}${right}`;
|
||||
}
|
||||
|
||||
function agentSymbol(status: HandoffRecord["status"]): string {
|
||||
const group = groupHandoffStatus(status);
|
||||
if (group === "running") return "🤖";
|
||||
if (group === "idle") return "💬";
|
||||
if (group === "error") return "⚠";
|
||||
if (group === "queued") return "◌";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function toDisplayRow(row: HandoffRecord): DisplayRow {
|
||||
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
|
||||
|
||||
const prLabel = row.prUrl
|
||||
? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}`
|
||||
: row.prSubmitted ? "sub" : "-";
|
||||
|
||||
const ciLabel = row.ciStatus ?? "-";
|
||||
const reviewLabel = row.reviewStatus
|
||||
? row.reviewStatus === "approved" ? "ok"
|
||||
: row.reviewStatus === "changes_requested" ? "chg"
|
||||
: row.reviewStatus === "pending" ? "..." : row.reviewStatus
|
||||
: "-";
|
||||
|
||||
return {
|
||||
name: `${conflictPrefix}${row.title || row.branchName}`,
|
||||
diff: row.diffStat ?? "-",
|
||||
agent: agentSymbol(row.status),
|
||||
pr: prLabel,
|
||||
author: row.prAuthor ?? "-",
|
||||
ci: ciLabel,
|
||||
review: reviewLabel,
|
||||
age: formatRelativeAge(row.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function helpLines(width: number): string[] {
|
||||
const popupWidth = Math.max(40, Math.min(width - 2, 100));
|
||||
const innerWidth = Math.max(2, popupWidth - 2);
|
||||
const borderTop = `┌${"─".repeat(innerWidth)}┐`;
|
||||
const borderBottom = `└${"─".repeat(innerWidth)}┘`;
|
||||
|
||||
const lines = [borderTop];
|
||||
for (const line of HELP_LINES) {
|
||||
lines.push(`│${pad(line, innerWidth)}│`);
|
||||
}
|
||||
lines.push(borderBottom);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatRows(
|
||||
rows: HandoffRecord[],
|
||||
selected: number,
|
||||
workspaceId: string,
|
||||
status: string,
|
||||
searchQuery = "",
|
||||
showHelp = false,
|
||||
options: RenderOptions = {}
|
||||
): string {
|
||||
const totalWidth = options.width ?? process.stdout.columns ?? 120;
|
||||
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
|
||||
const fixedWidth =
|
||||
COLUMN_WIDTHS.diff +
|
||||
COLUMN_WIDTHS.agent +
|
||||
COLUMN_WIDTHS.pr +
|
||||
COLUMN_WIDTHS.author +
|
||||
COLUMN_WIDTHS.ci +
|
||||
COLUMN_WIDTHS.review +
|
||||
COLUMN_WIDTHS.age;
|
||||
const separators = 7;
|
||||
const prefixWidth = 2;
|
||||
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
|
||||
|
||||
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
|
||||
const header = [
|
||||
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
|
||||
"-".repeat(Math.max(24, Math.min(totalWidth, 180)))
|
||||
];
|
||||
|
||||
const body =
|
||||
rows.length === 0
|
||||
? ["No branches found."]
|
||||
: rows.map((row, index) => {
|
||||
const marker = index === selected ? "┃ " : " ";
|
||||
const display = toDisplayRow(row);
|
||||
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
|
||||
});
|
||||
|
||||
const footer = fitLine(
|
||||
buildFooterLine(
|
||||
totalWidth,
|
||||
["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status],
|
||||
`v${CLI_BUILD_ID}`
|
||||
),
|
||||
totalWidth,
|
||||
);
|
||||
|
||||
const contentHeight = totalHeight - 1;
|
||||
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
|
||||
const page = lines.slice(0, contentHeight);
|
||||
while (page.length < contentHeight) {
|
||||
page.push(" ".repeat(totalWidth));
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
const popup = helpLines(totalWidth);
|
||||
const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2));
|
||||
for (let i = 0; i < popup.length; i += 1) {
|
||||
const target = startRow + i;
|
||||
if (target >= page.length) {
|
||||
break;
|
||||
}
|
||||
const popupLine = popup[i] ?? "";
|
||||
const popupLen = Array.from(popupLine).length;
|
||||
const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2));
|
||||
page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
return [...page, footer].join("\n");
|
||||
}
|
||||
|
||||
interface OpenTuiLike {
|
||||
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
|
||||
TextRenderable?: new (ctx: any, options: { id: string; content: string }) => {
|
||||
content: unknown;
|
||||
fg?: string;
|
||||
bg?: string;
|
||||
};
|
||||
fg?: (color: string) => (input: unknown) => unknown;
|
||||
bg?: (color: string) => (input: unknown) => unknown;
|
||||
StyledText?: new (chunks: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
interface StyledTextApi {
|
||||
fg: (color: string) => (input: unknown) => unknown;
|
||||
bg: (color: string) => (input: unknown) => unknown;
|
||||
StyledText: new (chunks: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown {
|
||||
const lines = content.split("\n");
|
||||
const chunks: unknown[] = [];
|
||||
const footerIndex = Math.max(0, lines.length - 1);
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? "";
|
||||
|
||||
let fgColor = theme.text;
|
||||
let bgColor: string | undefined;
|
||||
|
||||
if (line.startsWith("┃ ")) {
|
||||
const marker = "┃ ";
|
||||
const rest = line.slice(marker.length);
|
||||
bgColor = theme.highlightBg;
|
||||
const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker));
|
||||
const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest));
|
||||
chunks.push(markerChunk);
|
||||
chunks.push(restChunk);
|
||||
if (i < lines.length - 1) {
|
||||
chunks.push(api.fg(theme.text)("\n"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
fgColor = theme.header;
|
||||
} else if (i === 1) {
|
||||
fgColor = theme.muted;
|
||||
} else if (i === footerIndex) {
|
||||
fgColor = theme.status;
|
||||
} else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) {
|
||||
fgColor = theme.info;
|
||||
}
|
||||
|
||||
let chunk: unknown = api.fg(fgColor)(line);
|
||||
if (bgColor) {
|
||||
chunk = api.bg(bgColor)(chunk);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
|
||||
if (i < lines.length - 1) {
|
||||
chunks.push(api.fg(theme.text)("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return new api.StyledText(chunks);
|
||||
}
|
||||
|
||||
export async function runTui(config: AppConfig, workspaceId: string): Promise<void> {
|
||||
const core = (await import("@opentui/core")) as OpenTuiLike;
|
||||
const createCliRenderer = core.createCliRenderer;
|
||||
const TextRenderable = core.TextRenderable;
|
||||
const styleApi =
|
||||
core.fg && core.bg && core.StyledText
|
||||
? { fg: core.fg, bg: core.bg, StyledText: core.StyledText }
|
||||
: null;
|
||||
|
||||
if (!createCliRenderer || !TextRenderable) {
|
||||
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
|
||||
}
|
||||
|
||||
const themeResolution = resolveTuiTheme(config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "factory-switch",
|
||||
content: "Loading..."
|
||||
});
|
||||
text.fg = themeResolution.theme.text;
|
||||
text.bg = themeResolution.theme.background;
|
||||
renderer.root.add(text);
|
||||
renderer.start();
|
||||
|
||||
let allRows: HandoffRecord[] = [];
|
||||
let filteredRows: HandoffRecord[] = [];
|
||||
let selected = 0;
|
||||
let searchQuery = "";
|
||||
let showHelp = false;
|
||||
let status = "loading...";
|
||||
let busy = false;
|
||||
let closed = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const clampSelected = (): void => {
|
||||
if (filteredRows.length === 0) {
|
||||
selected = 0;
|
||||
return;
|
||||
}
|
||||
if (selected < 0) {
|
||||
selected = 0;
|
||||
return;
|
||||
}
|
||||
if (selected >= filteredRows.length) {
|
||||
selected = filteredRows.length - 1;
|
||||
}
|
||||
};
|
||||
|
||||
const render = (): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
|
||||
width: renderer.width ?? process.stdout.columns,
|
||||
height: renderer.height ?? process.stdout.rows
|
||||
});
|
||||
text.content = styleApi
|
||||
? buildStyledContent(output, themeResolution.theme, styleApi)
|
||||
: output;
|
||||
renderer.requestRender();
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
allRows = await client.listHandoffs(workspaceId);
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
filteredRows = filterHandoffs(allRows, searchQuery);
|
||||
clampSelected();
|
||||
status = `handoffs=${allRows.length} filtered=${filteredRows.length}`;
|
||||
} catch (err) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
const selectedRow = (): HandoffRecord | null => {
|
||||
if (filteredRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filteredRows[selected] ?? null;
|
||||
};
|
||||
|
||||
let resolveDone: () => void = () => {};
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resolveDone = () => resolve();
|
||||
});
|
||||
|
||||
const close = (output?: string): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
process.off("SIGINT", handleSignal);
|
||||
process.off("SIGTERM", handleSignal);
|
||||
renderer.destroy();
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
resolveDone();
|
||||
};
|
||||
|
||||
const handleSignal = (): void => {
|
||||
close();
|
||||
};
|
||||
|
||||
const runActionWithRefresh = async (
|
||||
label: string,
|
||||
fn: () => Promise<void>,
|
||||
success: string
|
||||
): Promise<void> => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `${label}...`;
|
||||
render();
|
||||
try {
|
||||
await fn();
|
||||
status = success;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
await refresh();
|
||||
timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 10_000);
|
||||
process.once("SIGINT", handleSignal);
|
||||
process.once("SIGTERM", handleSignal);
|
||||
|
||||
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as
|
||||
| { on: (name: string, cb: (event: KeyEventLike) => void) => void }
|
||||
| undefined;
|
||||
|
||||
if (!keyInput) {
|
||||
clearInterval(timer);
|
||||
renderer.destroy();
|
||||
throw new Error("OpenTUI key input handler is unavailable");
|
||||
}
|
||||
|
||||
keyInput.on("keypress", (event: KeyEventLike) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = event.name ?? "";
|
||||
const ctrl = Boolean(event.ctrl);
|
||||
|
||||
if (ctrl && name === "h") {
|
||||
showHelp = !showHelp;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
if (name === "escape") {
|
||||
showHelp = false;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "q" || name === "escape" || (ctrl && name === "c")) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ctrl && name === "n") || name === "down") {
|
||||
if (filteredRows.length > 0) {
|
||||
selected = selected >= filteredRows.length - 1 ? 0 : selected + 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ctrl && name === "p") || name === "up") {
|
||||
if (filteredRows.length > 0) {
|
||||
selected = selected <= 0 ? filteredRows.length - 1 : selected - 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "backspace") {
|
||||
searchQuery = searchQuery.slice(0, -1);
|
||||
filteredRows = filterHandoffs(allRows, searchQuery);
|
||||
selected = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "return" || name === "enter") {
|
||||
const row = selectedRow();
|
||||
if (!row || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `switching ${row.handoffId}...`;
|
||||
render();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.switchHandoff(workspaceId, row.handoffId);
|
||||
close(`cd ${result.switchTarget}`);
|
||||
} catch (err) {
|
||||
busy = false;
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "a") {
|
||||
const row = selectedRow();
|
||||
if (!row || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `attaching ${row.handoffId}...`;
|
||||
render();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.attachHandoff(workspaceId, row.handoffId);
|
||||
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
} catch (err) {
|
||||
busy = false;
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "x") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`archiving ${row.handoffId}`,
|
||||
async () => client.runAction(workspaceId, row.handoffId, "archive"),
|
||||
`archived ${row.handoffId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "s") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`syncing ${row.handoffId}`,
|
||||
async () => client.runAction(workspaceId, row.handoffId, "sync"),
|
||||
`synced ${row.handoffId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "y") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`merging ${row.handoffId}`,
|
||||
async () => {
|
||||
await client.runAction(workspaceId, row.handoffId, "merge");
|
||||
await client.runAction(workspaceId, row.handoffId, "archive");
|
||||
},
|
||||
`merged+archived ${row.handoffId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "o") {
|
||||
const row = selectedRow();
|
||||
if (!row?.prUrl) {
|
||||
status = "no PR URL available for this handoff";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
|
||||
status = `opened ${row.prUrl}`;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !event.meta && name.length === 1) {
|
||||
searchQuery += name;
|
||||
filteredRows = filterHandoffs(allRows, searchQuery);
|
||||
selected = 0;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
await done;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import * as toml from "@iarna/toml";
|
||||
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export const CONFIG_PATH = `${homedir()}/.config/sandbox-agent-factory/config.toml`;
|
||||
|
||||
export function loadConfig(path = CONFIG_PATH): AppConfig {
|
||||
if (!existsSync(path)) {
|
||||
return ConfigSchema.parse({});
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
return ConfigSchema.parse(toml.parse(raw));
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig, path = CONFIG_PATH): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, toml.stringify(config), "utf8");
|
||||
}
|
||||
|
||||
export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string {
|
||||
return resolveWorkspaceId(flagWorkspace, config);
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawn: spawnMock,
|
||||
execFileSync: execFileSyncMock
|
||||
};
|
||||
});
|
||||
|
||||
import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
|
||||
function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string {
|
||||
const sanitized = host
|
||||
.split("")
|
||||
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
|
||||
.join("");
|
||||
|
||||
return join(baseDir, `backend-${sanitized}-${port}.${suffix}`);
|
||||
}
|
||||
|
||||
function healthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
runtime: "rivetkit",
|
||||
actorNames: {
|
||||
workspace: {}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({})
|
||||
};
|
||||
}
|
||||
|
||||
describe("backend manager", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStateDir = process.env.HF_BACKEND_STATE_DIR;
|
||||
const originalBuildId = process.env.HF_BUILD_ID;
|
||||
|
||||
const config: AppConfig = ConfigSchema.parse({
|
||||
auto_submit: true,
|
||||
notify: ["terminal"],
|
||||
workspace: { default: "default" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HF_BUILD_ID = "test-build";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
spawnMock.mockReset();
|
||||
execFileSyncMock.mockReset();
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.HF_BACKEND_STATE_DIR;
|
||||
} else {
|
||||
process.env.HF_BACKEND_STATE_DIR = originalStateDir;
|
||||
}
|
||||
|
||||
if (originalBuildId === undefined) {
|
||||
delete process.env.HF_BUILD_ID;
|
||||
} else {
|
||||
process.env.HF_BUILD_ID = originalBuildId;
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts backend when healthy but build is outdated", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
|
||||
process.env.HF_BACKEND_STATE_DIR = stateDir;
|
||||
|
||||
const pidPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "pid");
|
||||
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
|
||||
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(pidPath, "999999", "utf8");
|
||||
writeFileSync(versionPath, "old-build", "utf8");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
.mockResolvedValueOnce(healthyMetadataResponse())
|
||||
.mockResolvedValueOnce(unhealthyMetadataResponse())
|
||||
.mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const fakeChild = Object.assign(new EventEmitter(), {
|
||||
pid: process.pid,
|
||||
unref: vi.fn()
|
||||
}) as unknown as ChildProcess;
|
||||
spawnMock.mockReturnValue(fakeChild);
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const launchCommand = spawnMock.mock.calls[0]?.[0];
|
||||
const launchArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined;
|
||||
expect(
|
||||
launchCommand === "pnpm" ||
|
||||
launchCommand === "bun" ||
|
||||
(typeof launchCommand === "string" && launchCommand.endsWith("/bun"))
|
||||
).toBe(true);
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)])
|
||||
);
|
||||
if (launchCommand === "pnpm") {
|
||||
expect(launchArgs).toEqual(expect.arrayContaining(["exec", "bun", "src/index.ts"]));
|
||||
}
|
||||
expect(readFileSync(pidPath, "utf8").trim()).toBe(String(process.pid));
|
||||
expect(readFileSync(versionPath, "utf8").trim()).toBe("test-build");
|
||||
});
|
||||
|
||||
it("does not restart when backend is healthy and build is current", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
|
||||
process.env.HF_BACKEND_STATE_DIR = stateDir;
|
||||
|
||||
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(versionPath, "test-build", "utf8");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
.mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates backend port parsing", () => {
|
||||
expect(parseBackendPort(undefined, 7741)).toBe(7741);
|
||||
expect(parseBackendPort("8080", 7741)).toBe(8080);
|
||||
expect(() => parseBackendPort("0", 7741)).toThrow("Invalid backend port");
|
||||
expect(() => parseBackendPort("abc", 7741)).toThrow("Invalid backend port");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeEditorTask } from "../src/task-editor.js";
|
||||
|
||||
describe("task editor helpers", () => {
|
||||
it("strips comment lines and trims whitespace", () => {
|
||||
const value = sanitizeEditorTask(`
|
||||
# comment
|
||||
Implement feature
|
||||
|
||||
# another comment
|
||||
with more detail
|
||||
`);
|
||||
|
||||
expect(value).toBe("Implement feature\n\nwith more detail");
|
||||
});
|
||||
|
||||
it("returns empty string when only comments are present", () => {
|
||||
const value = sanitizeEditorTask(`
|
||||
# hello
|
||||
# world
|
||||
`);
|
||||
|
||||
expect(value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import { resolveTuiTheme } from "../src/theme.js";
|
||||
|
||||
function withEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
describe("resolveTuiTheme", () => {
|
||||
let tempDir: string | null = null;
|
||||
const originalState = process.env.XDG_STATE_HOME;
|
||||
const originalConfig = process.env.XDG_CONFIG_HOME;
|
||||
|
||||
const baseConfig: AppConfig = ConfigSchema.parse({
|
||||
auto_submit: true,
|
||||
notify: ["terminal"],
|
||||
workspace: { default: "default" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
withEnv("XDG_STATE_HOME", originalState);
|
||||
withEnv("XDG_CONFIG_HOME", originalConfig);
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default theme when no theme sources are present", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("default");
|
||||
expect(resolution.theme.text).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("loads theme from opencode state when configured", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
const stateHome = join(tempDir, "state");
|
||||
const configHome = join(tempDir, "config");
|
||||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateHome, "opencode", "kv.json"),
|
||||
JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("gruvbox");
|
||||
expect(resolution.source).toContain("opencode state");
|
||||
expect(resolution.mode).toBe("dark");
|
||||
expect(resolution.theme.selectionBorder.toLowerCase()).not.toContain("dark");
|
||||
});
|
||||
|
||||
it("resolves OpenCode token references in theme defs", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
const stateHome = join(tempDir, "state");
|
||||
const configHome = join(tempDir, "config");
|
||||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateHome, "opencode", "kv.json"),
|
||||
JSON.stringify({ theme: "orng", theme_mode: "dark" }),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("orng");
|
||||
expect(resolution.theme.selectionBorder).toBe("#EE7948");
|
||||
expect(resolution.theme.background).toBe("#0a0a0a");
|
||||
});
|
||||
|
||||
it("prefers explicit factory theme override from config", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
||||
const config = { ...baseConfig, theme: "default" } as AppConfig & { theme: string };
|
||||
const resolution = resolveTuiTheme(config, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("factory config");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stripStatusPrefix } from "../src/tmux.js";
|
||||
|
||||
describe("tmux helpers", () => {
|
||||
it("strips running and idle markers from window names", () => {
|
||||
expect(stripStatusPrefix("▶ feature/auth")).toBe("feature/auth");
|
||||
expect(stripStatusPrefix("✓ feature/auth")).toBe("feature/auth");
|
||||
expect(stripStatusPrefix("feature/auth")).toBe("feature/auth");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { filterHandoffs, fuzzyMatch } from "@sandbox-agent/factory-client";
|
||||
import { formatRows } from "../src/tui.js";
|
||||
|
||||
const sample: HandoffRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
handoffId: "handoff-1",
|
||||
branchName: "feature/test",
|
||||
title: "Test Title",
|
||||
task: "Do test",
|
||||
providerId: "daytona",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
activeSessionId: "session-1",
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "daytona",
|
||||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
diffStat: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: null,
|
||||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
|
||||
describe("formatRows", () => {
|
||||
it("renders rust-style table header and empty state", () => {
|
||||
const output = formatRows([], 0, "default", "ok");
|
||||
expect(output).toContain("Branch/PR (type to filter)");
|
||||
expect(output).toContain("No branches found.");
|
||||
expect(output).toContain("Ctrl-H:cheatsheet");
|
||||
expect(output).toContain("ok");
|
||||
});
|
||||
|
||||
it("marks selected row with highlight", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready");
|
||||
expect(output).toContain("┃ ");
|
||||
expect(output).toContain("Test Title");
|
||||
expect(output).toContain("Ctrl-H:cheatsheet");
|
||||
});
|
||||
|
||||
it("pins footer to the last terminal row", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready", "", false, {
|
||||
width: 80,
|
||||
height: 12
|
||||
});
|
||||
const lines = output.split("\n");
|
||||
expect(lines).toHaveLength(12);
|
||||
expect(lines[11]).toContain("Ctrl-H:cheatsheet");
|
||||
expect(lines[11]).toContain("v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("supports ordered fuzzy matching", () => {
|
||||
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
|
||||
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows across branch and title", () => {
|
||||
const rows: HandoffRecord[] = [
|
||||
sample,
|
||||
{
|
||||
...sample,
|
||||
handoffId: "handoff-2",
|
||||
branchName: "docs/update-intro",
|
||||
title: "Docs Intro Refresh",
|
||||
status: "idle"
|
||||
}
|
||||
];
|
||||
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
|
||||
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
|
||||
expect(filterHandoffs(rows, "test")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
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