diff --git a/factory/CLAUDE.md b/factory/CLAUDE.md index 1eaa1b1..1234bbb 100644 --- a/factory/CLAUDE.md +++ b/factory/CLAUDE.md @@ -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. diff --git a/factory/CONTRIBUTING.md b/factory/CONTRIBUTING.md index 04a9e2d..c20299e 100644 --- a/factory/CONTRIBUTING.md +++ b/factory/CONTRIBUTING.md @@ -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 diff --git a/factory/README.md b/factory/README.md index a385089..700b7af 100644 --- a/factory/README.md +++ b/factory/README.md @@ -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 diff --git a/factory/packages/backend/src/actors/context.ts b/factory/packages/backend/src/actors/context.ts index 954a22a..34821b2 100644 --- a/factory/packages/backend/src/actors/context.ts +++ b/factory/packages/backend/src/actors/context.ts @@ -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, }; } diff --git a/factory/packages/backend/src/actors/events.ts b/factory/packages/backend/src/actors/events.ts index 958b105..9722665 100644 --- a/factory/packages/backend/src/actors/events.ts +++ b/factory/packages/backend/src/actors/events.ts @@ -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; } diff --git a/factory/packages/backend/src/actors/handles.ts b/factory/packages/backend/src/actors/handles.ts index 8f06f6a..acfaee7 100644 --- a/factory/packages/backend/src/actors/handles.ts +++ b/factory/packages/backend/src/actors/handles.ts @@ -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 + taskId: string, + createWithInput: Record, ) { - 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 + createWithInput: Record, ) { 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 + createWithInput: Record, ) { - 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) { diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts b/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts deleted file mode 100644 index 2a7346f..0000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts +++ /dev/null @@ -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", -}); - diff --git a/factory/packages/backend/src/actors/handoff/index.ts b/factory/packages/backend/src/actors/handoff/index.ts deleted file mode 100644 index 4715ad0..0000000 --- a/factory/packages/backend/src/actors/handoff/index.ts +++ /dev/null @@ -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; -} - -interface HandoffWorkbenchChangeModelCommand { - sessionId: string; - model: string; -} - -interface HandoffWorkbenchSendMessageCommand { - sessionId: string; - text: string; - attachments: Array; -} - -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 { - const self = selfHandoff(c); - const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }); - return expectQueueResponse(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 { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, { - wait: true, - timeout: 180_000 - }); - }, - - async sync(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, { - wait: true, - timeout: 30_000 - }); - }, - - async merge(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, { - wait: true, - timeout: 30_000 - }); - }, - - async archive(c, cmd?: HandoffActionCommand): Promise { - 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 { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, { - wait: true, - timeout: 60_000 - }); - }, - - async get(c): Promise { - return await getCurrentRecord({ db: c.db, state: c.state }); - }, - - async getWorkbench(c) { - return await getWorkbenchHandoff(c); - }, - - async markWorkbenchUnread(c): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), {}, { - wait: true, - timeout: 20_000, - }); - }, - - async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 }; diff --git a/factory/packages/backend/src/actors/handoff/workflow/queue.ts b/factory/packages/backend/src/actors/handoff/workflow/queue.ts deleted file mode 100644 index 8a64174..0000000 --- a/factory/packages/backend/src/actors/handoff/workflow/queue.ts +++ /dev/null @@ -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; -} diff --git a/factory/packages/backend/src/actors/history/db/migrations.ts b/factory/packages/backend/src/actors/history/db/migrations.ts index 36fbadc..202c607 100644 --- a/factory/packages/backend/src/actors/history/db/migrations.ts +++ b/factory/packages/backend/src/actors/history/db/migrations.ts @@ -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, diff --git a/factory/packages/backend/src/actors/history/db/schema.ts b/factory/packages/backend/src/actors/history/db/schema.ts index 1b8a5da..d015872 100644 --- a/factory/packages/backend/src/actors/history/db/schema.ts +++ b/factory/packages/backend/src/actors/history/db/schema.ts @@ -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(), diff --git a/factory/packages/backend/src/actors/history/index.ts b/factory/packages/backend/src/actors/history/index.ts index e051fc7..316d22d 100644 --- a/factory/packages/backend/src/actors/history/index.ts +++ b/factory/packages/backend/src/actors/history/index.ts @@ -14,14 +14,14 @@ export interface HistoryInput { export interface AppendHistoryCommand { kind: string; - handoffId?: string; + taskId?: string; branchName?: string; payload: Record; } 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 { 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, diff --git a/factory/packages/backend/src/actors/index.ts b/factory/packages/backend/src/actors/index.ts index 4d667fd..c53099f 100644 --- a/factory/packages/backend/src/actors/index.ts +++ b/factory/packages/backend/src/actors/index.ts @@ -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"; diff --git a/factory/packages/backend/src/actors/keys.ts b/factory/packages/backend/src/actors/keys.ts index 5c1eae9..e58ba0e 100644 --- a/factory/packages/backend/src/actors/keys.ts +++ b/factory/packages/backend/src/actors/keys.ts @@ -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]; } diff --git a/factory/packages/backend/src/actors/project/db/drizzle.config.ts b/factory/packages/backend/src/actors/project/db/drizzle.config.ts deleted file mode 100644 index c726278..0000000 --- a/factory/packages/backend/src/actors/project/db/drizzle.config.ts +++ /dev/null @@ -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", -}); - diff --git a/factory/packages/backend/src/actors/project/index.ts b/factory/packages/backend/src/actors/project/index.ts deleted file mode 100644 index cd33885..0000000 --- a/factory/packages/backend/src/actors/project/index.ts +++ /dev/null @@ -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), -}); diff --git a/factory/packages/backend/src/actors/project-branch-sync/index.ts b/factory/packages/backend/src/actors/repo-branch-sync/index.ts similarity index 78% rename from factory/packages/backend/src/actors/project-branch-sync/index.ts rename to factory/packages/backend/src/actors/repo-branch-sync/index.ts index abe8073..9c3be3a 100644 --- a/factory/packages/backend/src/actors/project-branch-sync/index.ts +++ b/factory/packages/backend/src/actors/repo-branch-sync/index.ts @@ -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 { +async function pollBranches(c: { state: RepoBranchSyncState }): Promise { 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 { - const self = selfProjectBranchSync(c); + const self = selfRepoBranchSync(c); await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); }, async stop(c): Promise { - const self = selfProjectBranchSync(c); + const self = selfRepoBranchSync(c); await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); }, async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfProjectBranchSync(c); + const self = selfRepoBranchSync(c); await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); }, async force(c): Promise { - 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(ctx, { - loopName: "project-branch-sync-loop", + await runWorkflowPollingLoop(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) }); diff --git a/factory/packages/backend/src/actors/project-pr-sync/index.ts b/factory/packages/backend/src/actors/repo-pr-sync/index.ts similarity index 68% rename from factory/packages/backend/src/actors/project-pr-sync/index.ts rename to factory/packages/backend/src/actors/repo-pr-sync/index.ts index 37737f6..ab3edc0 100644 --- a/factory/packages/backend/src/actors/project-pr-sync/index.ts +++ b/factory/packages/backend/src/actors/repo-pr-sync/index.ts @@ -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 { +async function pollPrs(c: { state: RepoPrSyncState }): Promise { 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 { - const self = selfProjectPrSync(c); + const self = selfRepoPrSync(c); await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); }, async stop(c): Promise { - const self = selfProjectPrSync(c); + const self = selfRepoPrSync(c); await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); }, async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfProjectPrSync(c); + const self = selfRepoPrSync(c); await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); }, async force(c): Promise { - 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(ctx, { - loopName: "project-pr-sync-loop", + await runWorkflowPollingLoop(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) }); diff --git a/factory/packages/backend/src/actors/project/actions.ts b/factory/packages/backend/src/actors/repo/actions.ts similarity index 65% rename from factory/packages/backend/src/actors/project/actions.ts rename to factory/packages/backend/src/actors/repo/actions.ts index fab3dfc..5945bd7 100644 --- a/factory/packages/backend/src/actors/project/actions.ts +++ b/factory/packages/backend/src/actors/repo/actions.ts @@ -4,8 +4,8 @@ import { and, desc, eq, isNotNull, ne } from "drizzle-orm"; import { Loop } from "rivetkit/workflow"; import type { AgentType, - HandoffRecord, - HandoffSummary, + TaskRecord, + TaskSummary, ProviderId, RepoOverview, RepoStackAction, @@ -13,31 +13,32 @@ import type { } from "@sandbox-agent/factory-shared"; import { getActorRuntimeContext } from "../context.js"; import { - getHandoff, - getOrCreateHandoff, + getTask, + getOrCreateTask, getOrCreateHistory, - getOrCreateProjectBranchSync, - getOrCreateProjectPrSync, - selfProject + getOrCreateRepoBranchSync, + getOrCreateRepoPrSync, + selfRepo } from "../handles.js"; import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js"; import { factoryRepoClonePath } from "../../services/factory-paths.js"; import { expectQueueResponse } from "../../services/queue.js"; import { withRepoGitLock } from "../../services/repo-git-lock.js"; -import { branches, handoffIndex, prCache, repoMeta } from "./db/schema.js"; +import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js"; import { deriveFallbackTitle } from "../../services/create-flow.js"; import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js"; import { sortBranchesForOverview } from "./stack-model.js"; -interface EnsureProjectCommand { +interface EnsureRepoCommand { remoteUrl: string; } -interface EnsureProjectResult { +interface EnsureRepoResult { localPath: string; } -interface CreateHandoffCommand { +interface CreateTaskCommand { + repoIds?: string[]; task: string; providerId: ProviderId; agentType: AgentType | null; @@ -46,22 +47,27 @@ interface CreateHandoffCommand { onBranch: string | null; } -interface HydrateHandoffIndexCommand {} +interface HydrateTaskIndexCommand {} interface ListReservedBranchesCommand {} -interface RegisterHandoffBranchCommand { - handoffId: string; +interface LinkTaskCommand { + taskId: string; + branchName?: string | null; +} + +interface RegisterTaskBranchCommand { + taskId: string; branchName: string; requireExistingRemote?: boolean; } -interface ListHandoffSummariesCommand { +interface ListTaskSummariesCommand { includeArchived?: boolean; } -interface GetHandoffEnrichedCommand { - handoffId: string; +interface GetTaskEnrichedCommand { + taskId: string; } interface GetPullRequestForBranchCommand { @@ -105,21 +111,22 @@ interface RunRepoStackActionCommand { parentBranch?: string; } -const PROJECT_QUEUE_NAMES = [ - "project.command.ensure", - "project.command.hydrateHandoffIndex", - "project.command.createHandoff", - "project.command.registerHandoffBranch", - "project.command.runRepoStackAction", - "project.command.applyPrSyncResult", - "project.command.applyBranchSyncResult", +const REPO_QUEUE_NAMES = [ + "repo.command.ensure", + "repo.command.hydrateTaskIndex", + "repo.command.createTask", + "repo.command.linkTask", + "repo.command.registerTaskBranch", + "repo.command.runRepoStackAction", + "repo.command.applyPrSyncResult", + "repo.command.applyBranchSyncResult", ] as const; -type ProjectQueueName = (typeof PROJECT_QUEUE_NAMES)[number]; +type RepoQueueName = (typeof REPO_QUEUE_NAMES)[number]; -export { PROJECT_QUEUE_NAMES }; +export { REPO_QUEUE_NAMES }; -export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueName { +export function repoWorkflowQueueName(name: RepoQueueName): RepoQueueName { return name; } @@ -131,77 +138,77 @@ async function ensureLocalClone(c: any, remoteUrl: string): Promise { return localPath; } -async function ensureProjectSyncActors(c: any, localPath: string): Promise { +async function ensureRepoSyncActors(c: any, localPath: string): Promise { if (c.state.syncActorsStarted) { return; } - const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000); + const prSync = await getOrCreateRepoPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000); await prSync.start(); - const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000); + const branchSync = await getOrCreateRepoBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000); await branchSync.start(); c.state.syncActorsStarted = true; } -async function deleteStaleHandoffIndexRow(c: any, handoffId: string): Promise { +async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise { try { - await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run(); + await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run(); } catch { // Best-effort cleanup only; preserve the original caller flow. } } -function isStaleHandoffReferenceError(error: unknown): boolean { +function isStaleTaskReferenceError(error: unknown): boolean { const message = resolveErrorMessage(error); - return isActorNotFoundError(error) || message.startsWith("Handoff not found:"); + return isActorNotFoundError(error) || message.startsWith("Task not found:"); } -async function ensureHandoffIndexHydrated(c: any): Promise { - if (c.state.handoffIndexHydrated) { +async function ensureTaskIndexHydrated(c: any): Promise { + if (c.state.taskIndexHydrated) { return; } const existing = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) + .select({ taskId: taskIndex.taskId }) + .from(taskIndex) .limit(1) .get(); if (existing) { - c.state.handoffIndexHydrated = true; + c.state.taskIndexHydrated = true; return; } - // Migration path for old project actors that only tracked handoffs in history. + // Migration path for old repo actors that only tracked tasks in history. try { const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); const rows = await history.list({ limit: 5_000 }); const seen = new Set(); - let skippedMissingHandoffActors = 0; + let skippedMissingTaskActors = 0; for (const row of rows) { - if (!row.handoffId || seen.has(row.handoffId)) { + if (!row.taskId || seen.has(row.taskId)) { continue; } - seen.add(row.handoffId); + seen.add(row.taskId); try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId); await h.get(); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - skippedMissingHandoffActors += 1; + if (isStaleTaskReferenceError(error)) { + skippedMissingTaskActors += 1; continue; } throw error; } await c.db - .insert(handoffIndex) + .insert(taskIndex) .values({ - handoffId: row.handoffId, + taskId: row.taskId, branchName: row.branchName, createdAt: row.createdAt, updatedAt: row.createdAt @@ -210,48 +217,48 @@ async function ensureHandoffIndexHydrated(c: any): Promise { .run(); } - if (skippedMissingHandoffActors > 0) { - logActorWarning("project", "skipped missing handoffs while hydrating index", { + if (skippedMissingTaskActors > 0) { + logActorWarning("repo", "skipped missing tasks while hydrating index", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - skippedMissingHandoffActors + skippedMissingTaskActors }); } } catch (error) { - logActorWarning("project", "handoff index hydration from history failed", { + logActorWarning("repo", "task index hydration from history failed", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, error: resolveErrorMessage(error) }); } - c.state.handoffIndexHydrated = true; + c.state.taskIndexHydrated = true; } -async function ensureProjectReady(c: any): Promise { +async function ensureRepoReady(c: any): Promise { if (!c.state.remoteUrl) { - throw new Error("project remoteUrl is not initialized"); + throw new Error("repo remoteUrl is not initialized"); } if (!c.state.localPath) { await ensureLocalClone(c, c.state.remoteUrl); } if (!c.state.localPath) { - throw new Error("project local repo is not initialized"); + throw new Error("repo local repo is not initialized"); } - await ensureProjectSyncActors(c, c.state.localPath); + await ensureRepoSyncActors(c, c.state.localPath); return c.state.localPath; } -async function ensureProjectReadyForRead(c: any): Promise { +async function ensureRepoReadyForRead(c: any): Promise { if (!c.state.remoteUrl) { - throw new Error("project remoteUrl is not initialized"); + throw new Error("repo remoteUrl is not initialized"); } if (!c.state.localPath || !c.state.syncActorsStarted) { - const result = await projectActions.ensure(c, { remoteUrl: c.state.remoteUrl }); + const result = await repoActions.ensure(c, { remoteUrl: c.state.remoteUrl }); const localPath = result?.localPath ?? c.state.localPath; if (!localPath) { - throw new Error("project local repo is not initialized"); + throw new Error("repo local repo is not initialized"); } return localPath; } @@ -259,22 +266,22 @@ async function ensureProjectReadyForRead(c: any): Promise { return c.state.localPath; } -async function ensureHandoffIndexHydratedForRead(c: any): Promise { - if (c.state.handoffIndexHydrated) { +async function ensureTaskIndexHydratedForRead(c: any): Promise { + if (c.state.taskIndexHydrated) { return; } - await projectActions.hydrateHandoffIndex(c, {}); + await repoActions.hydrateTaskIndex(c, {}); } -async function forceProjectSync(c: any, localPath: string): Promise { - const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000); +async function forceRepoSync(c: any, localPath: string): Promise { + const prSync = await getOrCreateRepoPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000); await prSync.force(); - const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000); + const branchSync = await getOrCreateRepoBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000); await branchSync.force(); } -async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise { +async function enrichTaskRecord(c: any, record: TaskRecord): Promise { const branchName = record.branchName; const br = branchName != null @@ -319,7 +326,7 @@ async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise { +async function ensureRepoMutation(c: any, cmd: EnsureRepoCommand): Promise { c.state.remoteUrl = cmd.remoteUrl; const localPath = await ensureLocalClone(c, cmd.remoteUrl); @@ -339,23 +346,45 @@ async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise }) .run(); - await ensureProjectSyncActors(c, localPath); + await ensureRepoSyncActors(c, localPath); return { localPath }; } -async function hydrateHandoffIndexMutation(c: any, _cmd?: HydrateHandoffIndexCommand): Promise { - await ensureHandoffIndexHydrated(c); +async function hydrateTaskIndexMutation(c: any, _cmd?: HydrateTaskIndexCommand): Promise { + await ensureTaskIndexHydrated(c); } -async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise { - const localPath = await ensureProjectReady(c); +async function linkTaskMutation(c: any, cmd: LinkTaskCommand): Promise { + await ensureTaskIndexHydrated(c); + const now = Date.now(); + await c.db + .insert(taskIndex) + .values({ + taskId: cmd.taskId, + branchName: cmd.branchName ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: taskIndex.taskId, + set: { + branchName: cmd.branchName ?? null, + updatedAt: now, + }, + }) + .run(); +} + +async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise { + const localPath = await ensureRepoReady(c); + const linkedRepoIds = [...new Set([c.state.repoId, ...(cmd.repoIds ?? [])])]; const onBranch = cmd.onBranch?.trim() || null; const initialBranchName = onBranch; const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null; - const handoffId = randomUUID(); + const taskId = randomUUID(); if (onBranch) { - await forceProjectSync(c, localPath); + await forceRepoSync(c, localPath); const branchRow = await c.db .select({ branchName: branches.branchName }) @@ -366,19 +395,20 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise throw new Error(`Branch not found in repo snapshot: ${onBranch}`); } - await registerHandoffBranchMutation(c, { - handoffId, + await registerTaskBranchMutation(c, { + taskId, branchName: onBranch, requireExistingRemote: true }); } - let handoff: Awaited>; + let task: Awaited>; try { - handoff = await getOrCreateHandoff(c, c.state.workspaceId, c.state.repoId, handoffId, { + task = await getOrCreateTask(c, c.state.workspaceId, c.state.repoId, taskId, { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId, + repoIds: linkedRepoIds, + taskId, repoRemote: c.state.remoteUrl, repoLocalPath: localPath, branchName: initialBranchName, @@ -391,7 +421,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise }); } catch (error) { if (onBranch) { - await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run().catch(() => {}); + await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run().catch(() => {}); } throw error; } @@ -399,9 +429,9 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise if (!onBranch) { const now = Date.now(); await c.db - .insert(handoffIndex) + .insert(taskIndex) .values({ - handoffId, + taskId, branchName: initialBranchName, createdAt: now, updatedAt: now @@ -410,12 +440,12 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise .run(); } - const created = await handoff.initialize({ providerId: cmd.providerId }); + const created = await task.initialize({ providerId: cmd.providerId }); const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); await history.append({ - kind: "handoff.created", - handoffId, + kind: "task.created", + taskId, payload: { repoId: c.state.repoId, providerId: cmd.providerId @@ -425,11 +455,11 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise return created; } -async function registerHandoffBranchMutation( +async function registerTaskBranchMutation( c: any, - cmd: RegisterHandoffBranchCommand, + cmd: RegisterTaskBranchCommand, ): Promise<{ branchName: string; headSha: string }> { - const localPath = await ensureProjectReady(c); + const localPath = await ensureRepoReady(c); const branchName = cmd.branchName.trim(); const requireExistingRemote = cmd.requireExistingRemote === true; @@ -437,27 +467,27 @@ async function registerHandoffBranchMutation( throw new Error("branchName is required"); } - await ensureHandoffIndexHydrated(c); + await ensureTaskIndexHydrated(c); const existingOwner = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .where(and(eq(handoffIndex.branchName, branchName), ne(handoffIndex.handoffId, cmd.handoffId))) + .select({ taskId: taskIndex.taskId }) + .from(taskIndex) + .where(and(eq(taskIndex.branchName, branchName), ne(taskIndex.taskId, cmd.taskId))) .get(); if (existingOwner) { let ownerMissing = false; try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, existingOwner.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, existingOwner.taskId); await h.get(); } catch (error) { - if (isStaleHandoffReferenceError(error)) { + if (isStaleTaskReferenceError(error)) { ownerMissing = true; - await deleteStaleHandoffIndexRow(c, existingOwner.handoffId); - logActorWarning("project", "pruned stale handoff index row during branch registration", { + await deleteStaleTaskIndexRow(c, existingOwner.taskId); + logActorWarning("repo", "pruned stale task index row during branch registration", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: existingOwner.handoffId, + taskId: existingOwner.taskId, branchName }); } else { @@ -465,7 +495,7 @@ async function registerHandoffBranchMutation( } } if (!ownerMissing) { - throw new Error(`branch is already assigned to a different handoff: ${branchName}`); + throw new Error(`branch is already assigned to a different task: ${branchName}`); } } @@ -504,7 +534,7 @@ async function registerHandoffBranchMutation( try { await driver.stack.trackBranch(localPath, branchName, normalizedBase); } catch (error) { - logActorWarning("project", "stack track failed while registering branch", { + logActorWarning("repo", "stack track failed while registering branch", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, branchName, @@ -545,15 +575,15 @@ async function registerHandoffBranchMutation( .run(); await c.db - .insert(handoffIndex) + .insert(taskIndex) .values({ - handoffId: cmd.handoffId, + taskId: cmd.taskId, branchName, createdAt: now, updatedAt: now }) .onConflictDoUpdate({ - target: handoffIndex.handoffId, + target: taskIndex.taskId, set: { branchName, updatedAt: now @@ -565,8 +595,8 @@ async function registerHandoffBranchMutation( } async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand): Promise { - const localPath = await ensureProjectReady(c); - await ensureHandoffIndexHydrated(c); + const localPath = await ensureRepoReady(c); + await ensureTaskIndexHydrated(c); const { driver } = getActorRuntimeContext(); const at = Date.now(); @@ -590,7 +620,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand throw new Error("parentBranch is required for action: reparent_branch"); } - await forceProjectSync(c, localPath); + await forceRepoSync(c, localPath); if (branchName) { const row = await c.db @@ -636,7 +666,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand } }); - await forceProjectSync(c, localPath); + await forceRepoSync(c, localPath); try { const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); @@ -650,7 +680,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand } }); } catch (error) { - logActorWarning("project", "failed appending repo stack history event", { + logActorWarning("repo", "failed appending repo stack history event", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, action, @@ -711,33 +741,33 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise { - await ctx.loop("project-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-project-command", { +export async function runRepoWorkflow(ctx: any): Promise { + await ctx.loop("repo-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-repo-command", { names: [...PROJECT_QUEUE_NAMES], completable: true, }); @@ -812,47 +842,57 @@ export async function runProjectWorkflow(ctx: any): Promise { return Loop.continue(undefined); } - if (msg.name === "project.command.ensure") { + if (msg.name === "repo.command.ensure") { const result = await loopCtx.step({ - name: "project-ensure", + name: "repo-ensure", timeout: 5 * 60_000, - run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand), + run: async () => ensureRepoMutation(loopCtx, msg.body as EnsureRepoCommand), }); await msg.complete(result); return Loop.continue(undefined); } - if (msg.name === "project.command.hydrateHandoffIndex") { - await loopCtx.step("project-hydrate-handoff-index", async () => - hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand), + if (msg.name === "repo.command.hydrateTaskIndex") { + await loopCtx.step("repo-hydrate-task-index", async () => + hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand), ); await msg.complete({ ok: true }); return Loop.continue(undefined); } - if (msg.name === "project.command.createHandoff") { - const result = await loopCtx.step({ - name: "project-create-handoff", - timeout: 12 * 60_000, - run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand), + if (msg.name === "repo.command.createTask") { + const result = await loopCtx.step({ + name: "repo-create-task", + timeout: 12 * 60_000, + run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand), }); await msg.complete(result); return Loop.continue(undefined); } - if (msg.name === "project.command.registerHandoffBranch") { + if (msg.name === "repo.command.registerTaskBranch") { const result = await loopCtx.step({ - name: "project-register-handoff-branch", + name: "repo-register-task-branch", timeout: 5 * 60_000, - run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand), + run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand), }); - await msg.complete(result); - return Loop.continue(undefined); - } + await msg.complete(result); + return Loop.continue(undefined); + } - if (msg.name === "project.command.runRepoStackAction") { + if (msg.name === "repo.command.linkTask") { + await loopCtx.step({ + name: "repo-link-task", + timeout: 60_000, + run: async () => linkTaskMutation(loopCtx, msg.body as LinkTaskCommand), + }); + await msg.complete({ ok: true }); + return Loop.continue(undefined); + } + + if (msg.name === "repo.command.runRepoStackAction") { const result = await loopCtx.step({ - name: "project-run-repo-stack-action", + name: "repo-run-repo-stack-action", timeout: 12 * 60_000, run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand), }); @@ -860,9 +900,9 @@ export async function runProjectWorkflow(ctx: any): Promise { return Loop.continue(undefined); } - if (msg.name === "project.command.applyPrSyncResult") { + if (msg.name === "repo.command.applyPrSyncResult") { await loopCtx.step({ - name: "project-apply-pr-sync-result", + name: "repo-apply-pr-sync-result", timeout: 60_000, run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult), }); @@ -870,9 +910,9 @@ export async function runProjectWorkflow(ctx: any): Promise { return Loop.continue(undefined); } - if (msg.name === "project.command.applyBranchSyncResult") { + if (msg.name === "repo.command.applyBranchSyncResult") { await loopCtx.step({ - name: "project-apply-branch-sync-result", + name: "repo-apply-branch-sync-result", timeout: 60_000, run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult), }); @@ -883,34 +923,42 @@ export async function runProjectWorkflow(ctx: any): Promise { }); } -export const projectActions = { - async ensure(c: any, cmd: EnsureProjectCommand): Promise { - const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.ensure"), cmd, { +export const repoActions = { + async ensure(c: any, cmd: EnsureRepoCommand): Promise { + const self = selfRepo(c); + return expectQueueResponse( + await self.send(repoWorkflowQueueName("repo.command.ensure"), cmd, { wait: true, timeout: 5 * 60_000, }), ); }, - async createHandoff(c: any, cmd: CreateHandoffCommand): Promise { - const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.createHandoff"), cmd, { + async createTask(c: any, cmd: CreateTaskCommand): Promise { + const self = selfRepo(c); + return expectQueueResponse( + await self.send(repoWorkflowQueueName("repo.command.createTask"), cmd, { wait: true, timeout: 12 * 60_000, }), ); }, + async linkTask(c: any, cmd: LinkTaskCommand): Promise { + const self = selfRepo(c); + await self.send(repoWorkflowQueueName("repo.command.linkTask"), cmd, { + wait: true, + timeout: 60_000, + }); + }, + async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise { - await ensureHandoffIndexHydratedForRead(c); + await ensureTaskIndexHydratedForRead(c); const rows = await c.db - .select({ branchName: handoffIndex.branchName }) - .from(handoffIndex) - .where(isNotNull(handoffIndex.branchName)) + .select({ branchName: taskIndex.branchName }) + .from(taskIndex) + .where(isNotNull(taskIndex.branchName)) .all(); return rows @@ -918,39 +966,39 @@ export const projectActions = { .filter((name): name is string => typeof name === "string" && name.trim().length > 0); }, - async registerHandoffBranch(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> { - const self = selfProject(c); + async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> { + const self = selfRepo(c); return expectQueueResponse<{ branchName: string; headSha: string }>( - await self.send(projectWorkflowQueueName("project.command.registerHandoffBranch"), cmd, { + await self.send(repoWorkflowQueueName("repo.command.registerTaskBranch"), cmd, { wait: true, timeout: 5 * 60_000, }), ); }, - async hydrateHandoffIndex(c: any, cmd?: HydrateHandoffIndexCommand): Promise { - const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.hydrateHandoffIndex"), cmd ?? {}, { + async hydrateTaskIndex(c: any, cmd?: HydrateTaskIndexCommand): Promise { + const self = selfRepo(c); + await self.send(repoWorkflowQueueName("repo.command.hydrateTaskIndex"), cmd ?? {}, { wait: true, timeout: 60_000, }); }, - async listHandoffSummaries(c: any, cmd?: ListHandoffSummariesCommand): Promise { + async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise { const body = cmd ?? {}; - const records: HandoffSummary[] = []; + const records: TaskSummary[] = []; - await ensureHandoffIndexHydratedForRead(c); + await ensureTaskIndexHydratedForRead(c); - const handoffRows = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .orderBy(desc(handoffIndex.updatedAt)) + const taskRows = await c.db + .select({ taskId: taskIndex.taskId }) + .from(taskIndex) + .orderBy(desc(taskIndex.updatedAt)) .all(); - for (const row of handoffRows) { + for (const row of taskRows) { try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId); const record = await h.get(); if (!body.includeArchived && record.status === "archived") { @@ -960,26 +1008,26 @@ export const projectActions = { records.push({ workspaceId: record.workspaceId, repoId: record.repoId, - handoffId: record.handoffId, + taskId: record.taskId, branchName: record.branchName, title: record.title, status: record.status, updatedAt: record.updatedAt }); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during summary listing", { + if (isStaleTaskReferenceError(error)) { + await deleteStaleTaskIndexRow(c, row.taskId); + logActorWarning("repo", "pruned stale task index row during summary listing", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId + taskId: row.taskId }); continue; } - logActorWarning("project", "failed loading handoff summary row", { + logActorWarning("repo", "failed loading task summary row", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, error: resolveErrorMessage(error) }); } @@ -989,35 +1037,35 @@ export const projectActions = { return records; }, - async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise { - await ensureHandoffIndexHydratedForRead(c); + async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise { + await ensureTaskIndexHydratedForRead(c); const row = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .where(eq(handoffIndex.handoffId, cmd.handoffId)) + .select({ taskId: taskIndex.taskId }) + .from(taskIndex) + .where(eq(taskIndex.taskId, cmd.taskId)) .get(); if (!row) { - throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); + throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); } try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, cmd.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, cmd.taskId); const record = await h.get(); - return await enrichHandoffRecord(c, record); + return await enrichTaskRecord(c, record); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, cmd.handoffId); - throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); + if (isStaleTaskReferenceError(error)) { + await deleteStaleTaskIndexRow(c, cmd.taskId); + throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); } throw error; } }, async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise { - const localPath = await ensureProjectReadyForRead(c); - await ensureHandoffIndexHydratedForRead(c); - await forceProjectSync(c, localPath); + const localPath = await ensureRepoReadyForRead(c); + await ensureTaskIndexHydratedForRead(c); + await forceRepoSync(c, localPath); const { driver } = getActorRuntimeContext(); const now = Date.now(); @@ -1040,48 +1088,48 @@ export const projectActions = { .from(branches) .all(); - const handoffRows = await c.db + const taskRows = await c.db .select({ - handoffId: handoffIndex.handoffId, - branchName: handoffIndex.branchName, - updatedAt: handoffIndex.updatedAt + taskId: taskIndex.taskId, + branchName: taskIndex.branchName, + updatedAt: taskIndex.updatedAt }) - .from(handoffIndex) + .from(taskIndex) .all(); - const handoffMetaByBranch = new Map< + const taskMetaByBranch = new Map< string, - { handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number } + { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number } >(); - for (const row of handoffRows) { + for (const row of taskRows) { if (!row.branchName) { continue; } try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId); const record = await h.get(); - handoffMetaByBranch.set(row.branchName, { - handoffId: row.handoffId, + taskMetaByBranch.set(row.branchName, { + taskId: row.taskId, title: record.title ?? null, status: record.status, updatedAt: record.updatedAt }); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during repo overview", { + if (isStaleTaskReferenceError(error)) { + await deleteStaleTaskIndexRow(c, row.taskId); + logActorWarning("repo", "pruned stale task index row during repo overview", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, branchName: row.branchName }); continue; } - logActorWarning("project", "failed loading handoff while building repo overview", { + logActorWarning("repo", "failed loading task while building repo overview", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, branchName: row.branchName, error: resolveErrorMessage(error) }); @@ -1114,7 +1162,7 @@ export const projectActions = { const branchRows = combinedRows.map((ordering) => { const row = detailByBranch.get(ordering.branchName)!; - const handoffMeta = handoffMetaByBranch.get(row.branchName); + const taskMeta = taskMetaByBranch.get(row.branchName); const pr = prByBranch.get(row.branchName); return { branchName: row.branchName, @@ -1124,9 +1172,9 @@ export const projectActions = { diffStat: row.diffStat ?? null, hasUnpushed: Boolean(row.hasUnpushed), conflictsWithMain: Boolean(row.conflictsWithMain), - handoffId: handoffMeta?.handoffId ?? null, - handoffTitle: handoffMeta?.title ?? null, - handoffStatus: handoffMeta?.status ?? null, + taskId: taskMeta?.taskId ?? null, + taskTitle: taskMeta?.title ?? null, + taskStatus: taskMeta?.status ?? null, prNumber: pr?.prNumber ?? null, prState: pr?.prState ?? null, prUrl: pr?.prUrl ?? null, @@ -1135,7 +1183,7 @@ export const projectActions = { reviewer: pr?.reviewer ?? null, firstSeenAt: row.firstSeenAt ?? null, lastSeenAt: row.lastSeenAt ?? null, - updatedAt: Math.max(row.updatedAt, handoffMeta?.updatedAt ?? 0) + updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0) }; }); @@ -1179,9 +1227,9 @@ export const projectActions = { }, async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise { - const self = selfProject(c); + const self = selfRepo(c); return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.runRepoStackAction"), cmd, { + await self.send(repoWorkflowQueueName("repo.command.runRepoStackAction"), cmd, { wait: true, timeout: 12 * 60_000, }), @@ -1189,16 +1237,16 @@ export const projectActions = { }, async applyPrSyncResult(c: any, body: PrSyncResult): Promise { - const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.applyPrSyncResult"), body, { + const self = selfRepo(c); + await self.send(repoWorkflowQueueName("repo.command.applyPrSyncResult"), body, { wait: true, timeout: 5 * 60_000, }); }, async applyBranchSyncResult(c: any, body: BranchSyncResult): Promise { - const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.applyBranchSyncResult"), body, { + const self = selfRepo(c); + await self.send(repoWorkflowQueueName("repo.command.applyBranchSyncResult"), body, { wait: true, timeout: 5 * 60_000, }); diff --git a/factory/packages/backend/src/actors/project/db/db.ts b/factory/packages/backend/src/actors/repo/db/db.ts similarity index 78% rename from factory/packages/backend/src/actors/project/db/db.ts rename to factory/packages/backend/src/actors/repo/db/db.ts index 20e8b22..de4aa44 100644 --- a/factory/packages/backend/src/actors/project/db/db.ts +++ b/factory/packages/backend/src/actors/repo/db/db.ts @@ -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), diff --git a/factory/packages/backend/src/actors/repo/db/drizzle.config.ts b/factory/packages/backend/src/actors/repo/db/drizzle.config.ts new file mode 100644 index 0000000..bf04342 --- /dev/null +++ b/factory/packages/backend/src/actors/repo/db/drizzle.config.ts @@ -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", +}); + diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql b/factory/packages/backend/src/actors/repo/db/drizzle/0000_stormy_the_hunter.sql similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql rename to factory/packages/backend/src/actors/repo/db/drizzle/0000_stormy_the_hunter.sql diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql b/factory/packages/backend/src/actors/repo/db/drizzle/0001_wild_carlie_cooper.sql similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql rename to factory/packages/backend/src/actors/repo/db/drizzle/0001_wild_carlie_cooper.sql diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql b/factory/packages/backend/src/actors/repo/db/drizzle/0002_far_war_machine.sql similarity index 80% rename from factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql rename to factory/packages/backend/src/actors/repo/db/drizzle/0002_far_war_machine.sql index 1ecd2ba..da809c0 100644 --- a/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql +++ b/factory/packages/backend/src/actors/repo/db/drizzle/0002_far_war_machine.sql @@ -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, diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql b/factory/packages/backend/src/actors/repo/db/drizzle/0003_busy_legacy.sql similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql rename to factory/packages/backend/src/actors/repo/db/drizzle/0003_busy_legacy.sql diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/repo/db/drizzle/meta/0000_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json rename to factory/packages/backend/src/actors/repo/db/drizzle/meta/0000_snapshot.json diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json b/factory/packages/backend/src/actors/repo/db/drizzle/meta/0001_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json rename to factory/packages/backend/src/actors/repo/db/drizzle/meta/0001_snapshot.json diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json b/factory/packages/backend/src/actors/repo/db/drizzle/meta/0002_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json rename to factory/packages/backend/src/actors/repo/db/drizzle/meta/0002_snapshot.json diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/repo/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json rename to factory/packages/backend/src/actors/repo/db/drizzle/meta/_journal.json diff --git a/factory/packages/backend/src/actors/project/db/migrations.ts b/factory/packages/backend/src/actors/repo/db/migrations.ts similarity index 95% rename from factory/packages/backend/src/actors/project/db/migrations.ts rename to factory/packages/backend/src/actors/repo/db/migrations.ts index 06844f5..eb9e980 100644 --- a/factory/packages/backend/src/actors/project/db/migrations.ts +++ b/factory/packages/backend/src/actors/repo/db/migrations.ts @@ -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 diff --git a/factory/packages/backend/src/actors/project/db/schema.ts b/factory/packages/backend/src/actors/repo/db/schema.ts similarity index 86% rename from factory/packages/backend/src/actors/project/db/schema.ts rename to factory/packages/backend/src/actors/repo/db/schema.ts index 1513368..8a220d2 100644 --- a/factory/packages/backend/src/actors/project/db/schema.ts +++ b/factory/packages/backend/src/actors/repo/db/schema.ts @@ -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() diff --git a/factory/packages/backend/src/actors/repo/index.ts b/factory/packages/backend/src/actors/repo/index.ts new file mode 100644 index 0000000..f042f3d --- /dev/null +++ b/factory/packages/backend/src/actors/repo/index.ts @@ -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), +}); diff --git a/factory/packages/backend/src/actors/project/stack-model.ts b/factory/packages/backend/src/actors/repo/stack-model.ts similarity index 100% rename from factory/packages/backend/src/actors/project/stack-model.ts rename to factory/packages/backend/src/actors/repo/stack-model.ts diff --git a/factory/packages/backend/src/actors/handoff-status-sync/index.ts b/factory/packages/backend/src/actors/task-status-sync/index.ts similarity index 67% rename from factory/packages/backend/src/actors/handoff-status-sync/index.ts rename to factory/packages/backend/src/actors/task-status-sync/index.ts index 296db21..3ceec50 100644 --- a/factory/packages/backend/src/actors/handoff-status-sync/index.ts +++ b/factory/packages/backend/src/actors/task-status-sync/index.ts @@ -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 { +async function pollSessionStatus(c: { state: TaskStatusSyncState }): Promise { 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 { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); }, async stop(c): Promise { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); }, async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); }, async force(c): Promise { - 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(ctx, { - loopName: "handoff-status-sync-loop", + await runWorkflowPollingLoop(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) }); diff --git a/factory/packages/backend/src/actors/handoff/db/db.ts b/factory/packages/backend/src/actors/task/db/db.ts similarity index 78% rename from factory/packages/backend/src/actors/handoff/db/db.ts rename to factory/packages/backend/src/actors/task/db/db.ts index 979bcf9..e32279d 100644 --- a/factory/packages/backend/src/actors/handoff/db/db.ts +++ b/factory/packages/backend/src/actors/task/db/db.ts @@ -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), diff --git a/factory/packages/backend/src/actors/task/db/drizzle.config.ts b/factory/packages/backend/src/actors/task/db/drizzle.config.ts new file mode 100644 index 0000000..e50d0d6 --- /dev/null +++ b/factory/packages/backend/src/actors/task/db/drizzle.config.ts @@ -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", +}); + diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql b/factory/packages/backend/src/actors/task/db/drizzle/0000_condemned_maria_hill.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0000_condemned_maria_hill.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql b/factory/packages/backend/src/actors/task/db/drizzle/0001_rapid_eddie_brock.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0001_rapid_eddie_brock.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql b/factory/packages/backend/src/actors/task/db/drizzle/0002_lazy_moira_mactaggert.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0002_lazy_moira_mactaggert.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql b/factory/packages/backend/src/actors/task/db/drizzle/0003_plucky_bran.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0003_plucky_bran.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql b/factory/packages/backend/src/actors/task/db/drizzle/0004_focused_shuri.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0004_focused_shuri.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql b/factory/packages/backend/src/actors/task/db/drizzle/0005_sandbox_actor_id.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0005_sandbox_actor_id.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql b/factory/packages/backend/src/actors/task/db/drizzle/0006_workbench_sessions.sql similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql rename to factory/packages/backend/src/actors/task/db/drizzle/0006_workbench_sessions.sql diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json rename to factory/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json b/factory/packages/backend/src/actors/task/db/drizzle/meta/0001_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json rename to factory/packages/backend/src/actors/task/db/drizzle/meta/0001_snapshot.json diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json b/factory/packages/backend/src/actors/task/db/drizzle/meta/0002_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json rename to factory/packages/backend/src/actors/task/db/drizzle/meta/0002_snapshot.json diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/task/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json rename to factory/packages/backend/src/actors/task/db/drizzle/meta/_journal.json diff --git a/factory/packages/backend/src/actors/handoff/db/migrations.ts b/factory/packages/backend/src/actors/task/db/migrations.ts similarity index 76% rename from factory/packages/backend/src/actors/handoff/db/migrations.ts rename to factory/packages/backend/src/actors/task/db/migrations.ts index 7fb3866..b37c871 100644 --- a/factory/packages/backend/src/actors/handoff/db/migrations.ts +++ b/factory/packages/backend/src/actors/task/db/migrations.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, diff --git a/factory/packages/backend/src/actors/handoff/db/schema.ts b/factory/packages/backend/src/actors/task/db/schema.ts similarity index 83% rename from factory/packages/backend/src/actors/handoff/db/schema.ts rename to factory/packages/backend/src/actors/task/db/schema.ts index 7ce1aca..a9d8ad8 100644 --- a/factory/packages/backend/src/actors/handoff/db/schema.ts +++ b/factory/packages/backend/src/actors/task/db/schema.ts @@ -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(), diff --git a/factory/packages/backend/src/actors/task/index.ts b/factory/packages/backend/src/actors/task/index.ts new file mode 100644 index 0000000..e1518c2 --- /dev/null +++ b/factory/packages/backend/src/actors/task/index.ts @@ -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; +} + +interface TaskWorkbenchChangeModelCommand { + sessionId: string; + model: string; +} + +interface TaskWorkbenchSendMessageCommand { + sessionId: string; + text: string; + attachments: Array; +} + +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 { + const self = selfTask(c); + const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, { + wait: true, + timeout: 60_000, + }); + return expectQueueResponse(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 { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, { + wait: true, + timeout: 180_000 + }); + }, + + async sync(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, { + wait: true, + timeout: 30_000 + }); + }, + + async merge(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, { + wait: true, + timeout: 30_000 + }); + }, + + async archive(c, cmd?: TaskActionCommand): Promise { + 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 { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, { + wait: true, + timeout: 60_000 + }); + }, + + async get(c): Promise { + return await getCurrentRecord({ db: c.db, state: c.state }); + }, + + async getWorkbench(c) { + return await getWorkbenchTask(c); + }, + + async markWorkbenchUnread(c): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workbench.mark_unread"), {}, { + wait: true, + timeout: 20_000, + }); + }, + + async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/factory/packages/backend/src/actors/handoff/workbench.ts b/factory/packages/backend/src/actors/task/workbench.ts similarity index 93% rename from factory/packages/backend/src/actors/handoff/workbench.ts rename to factory/packages/backend/src/actors/task/workbench.ts index 8fd5a53..5d3af10 100644 --- a/factory/packages/backend/src/actors/handoff/workbench.ts +++ b/factory/packages/backend/src/actors/task/workbench.ts @@ -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 { 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 { 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): Promise { 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 { return record; } -export async function getWorkbenchHandoff(c: any): Promise { +export async function getWorkbenchTask(c: any): Promise { 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 { } 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 { }; } -export async function renameWorkbenchHandoff(c: any, value: string): Promise { +export async function renameWorkbenchTask(c: any, value: string): Promise { 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 { 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); } diff --git a/factory/packages/backend/src/actors/handoff/workflow/commands.ts b/factory/packages/backend/src/actors/task/workflow/commands.ts similarity index 73% rename from factory/packages/backend/src/actors/handoff/workflow/commands.ts rename to factory/packages/backend/src/actors/task/workflow/commands.ts index a92995d..76c59e9 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/commands.ts +++ b/factory/packages/backend/src/actors/task/workflow/commands.ts @@ -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(promise: Promise, timeoutMs: number, label: string): Promise { @@ -36,7 +36,7 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise { 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 { 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 { 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 { - 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 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 { - 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 { - 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 { } export async function killWriteDbActivity(loopCtx: any, msg: any): Promise { - 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 }); } diff --git a/factory/packages/backend/src/actors/handoff/workflow/common.ts b/factory/packages/backend/src/actors/task/workflow/common.ts similarity index 69% rename from factory/packages/backend/src/actors/handoff/workflow/common.ts rename to factory/packages/backend/src/actors/task/workflow/common.ts index f517e11..8e5450a 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/common.ts +++ b/factory/packages/backend/src/actors/task/workflow/common.ts @@ -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 { 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 { +export async function getCurrentRecord(ctx: any): Promise { 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 { reviewer: null, createdAt: row.createdAt, updatedAt: row.updatedAt, - } as HandoffRecord; + } as TaskRecord; } export async function appendHistory(ctx: any, kind: string, payload: Record): Promise { @@ -182,7 +183,7 @@ export async function appendHistory(ctx: any, kind: string, payload: Record Promise }) => Promise; +type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise }) => Promise; -const commandHandlers: Record = { - "handoff.command.initialize": async (loopCtx, msg) => { +const commandHandlers: Record = { + "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 = { 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 = { } }, - "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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { } }; -export async function runHandoffWorkflow(ctx: any): Promise { - await ctx.loop("handoff-command-loop", async (loopCtx: any) => { +export async function runTaskWorkflow(ctx: any): Promise { + 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); } diff --git a/factory/packages/backend/src/actors/handoff/workflow/init.ts b/factory/packages/backend/src/actors/task/workflow/init.ts similarity index 81% rename from factory/packages/backend/src/actors/handoff/workflow/init.ts rename to factory/packages/backend/src/actors/task/workflow/init.ts index 62820f5..6b1859f 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/init.ts +++ b/factory/packages/backend/src/actors/task/workflow/init.ts @@ -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): 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 { - 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 { - 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 { 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 { (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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 }); diff --git a/factory/packages/backend/src/actors/handoff/workflow/push.ts b/factory/packages/backend/src/actors/task/workflow/push.ts similarity index 81% rename from factory/packages/backend/src/actors/handoff/workflow/push.ts rename to factory/packages/backend/src/actors/task/workflow/push.ts index fcd8d64..8e4517a 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/push.ts +++ b/factory/packages/backend/src/actors/task/workflow/push.ts @@ -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 diff --git a/factory/packages/backend/src/actors/task/workflow/queue.ts b/factory/packages/backend/src/actors/task/workflow/queue.ts new file mode 100644 index 0000000..b3022f9 --- /dev/null +++ b/factory/packages/backend/src/actors/task/workflow/queue.ts @@ -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; +} diff --git a/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts b/factory/packages/backend/src/actors/task/workflow/status-sync.ts similarity index 70% rename from factory/packages/backend/src/actors/handoff/workflow/status-sync.ts rename to factory/packages/backend/src/actors/task/workflow/status-sync.ts index e1d632d..1d76d6f 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts +++ b/factory/packages/backend/src/actors/task/workflow/status-sync.ts @@ -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 { 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 { 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 { ); 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 { } 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 }); diff --git a/factory/packages/backend/src/actors/workspace/actions.ts b/factory/packages/backend/src/actors/workspace/actions.ts index 50eb715..1a78ddd 100644 --- a/factory/packages/backend/src/actors/workspace/actions.ts +++ b/factory/packages/backend/src/actors/workspace/actions.ts @@ -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 { +async function resolveRepoId(c: any, taskId: string): Promise { 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 { +async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise { 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 { +async function collectAllTaskSummaries(c: any): Promise { 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(); 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 { } } - 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 { +async function buildWorkbenchSnapshot(c: any): Promise { 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 = []; - const projects: Array = []; + const tasksById = new Map(); + const repoSections: Array = []; for (const row of repoRows) { - const projectHandoffs: Array = []; + const repoTasks: Array = []; 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 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 { @@ -239,7 +250,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise }; } -async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise { +async function createTaskMutation(c: any, input: CreateTaskInput): Promise { 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 { 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 { } 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 { + async createTask(c: any, input: CreateTaskInput): Promise { const self = selfWorkspace(c); - return expectQueueResponse( - await self.send(workspaceWorkflowQueueName("workspace.command.createHandoff"), input, { + return expectQueueResponse( + await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, { wait: true, timeout: 12 * 60_000, }), ); }, - async getWorkbench(c: any, input: WorkspaceUseInput): Promise { + async getWorkbench(c: any, input: WorkspaceUseInput): Promise { 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 { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.markWorkbenchUnread({}); + async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.markWorkbenchUnread({}); }, - async renameWorkbenchHandoff(c: any, input: HandoffWorkbenchRenameInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchHandoff(input); + async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.renameWorkbenchTask(input); }, - async renameWorkbenchBranch(c: any, input: HandoffWorkbenchRenameInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchBranch(input); + async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise { + 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 { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchSession(input); + async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.renameWorkbenchSession(input); }, - async setWorkbenchSessionUnread(c: any, input: HandoffWorkbenchSetSessionUnreadInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.setWorkbenchSessionUnread(input); + async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.setWorkbenchSessionUnread(input); }, - async updateWorkbenchDraft(c: any, input: HandoffWorkbenchUpdateDraftInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.updateWorkbenchDraft(input); + async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.updateWorkbenchDraft(input); }, - async changeWorkbenchModel(c: any, input: HandoffWorkbenchChangeModelInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.changeWorkbenchModel(input); + async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.changeWorkbenchModel(input); }, - async sendWorkbenchMessage(c: any, input: HandoffWorkbenchSendMessageInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.sendWorkbenchMessage(input); + async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.sendWorkbenchMessage(input); }, - async stopWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.stopWorkbenchSession(input); + async stopWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.stopWorkbenchSession(input); }, - async closeWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.closeWorkbenchSession(input); + async closeWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.closeWorkbenchSession(input); }, - async publishWorkbenchPr(c: any, input: HandoffWorkbenchSelectInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.publishWorkbenchPr({}); + async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.publishWorkbenchPr({}); }, - async revertWorkbenchFile(c: any, input: HandoffWorkbenchDiffInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.revertWorkbenchFile(input); + async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.revertWorkbenchFile(input); }, - async listHandoffs(c: any, input: ListHandoffsInput): Promise { + async listTasks(c: any, input: ListTasksInput): Promise { 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 { @@ -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 { @@ -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 { - const repoId = await resolveRepoId(c, handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, handoffId); + async switchTask(c: any, taskId: string): Promise { + 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 { + async getTask(c: any, input: GetTaskInput): Promise { 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 { + async pushTask(c: any, input: TaskProxyActionInput): Promise { 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 { + async syncTask(c: any, input: TaskProxyActionInput): Promise { 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 { + async mergeTask(c: any, input: TaskProxyActionInput): Promise { 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 { + async archiveTask(c: any, input: TaskProxyActionInput): Promise { 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 { + async killTask(c: any, input: TaskProxyActionInput): Promise { 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 }); } }; diff --git a/factory/packages/backend/src/actors/workspace/app-shell.ts b/factory/packages/backend/src/actors/workspace/app-shell.ts new file mode 100644 index 0000000..c5b9bc2 --- /dev/null +++ b/factory/packages/backend/src/actors/workspace/app-shell.ts @@ -0,0 +1,1472 @@ +// @ts-nocheck +import { desc, eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import type { + FactoryAppSnapshot, + FactoryBillingPlanId, + FactoryBillingState, + FactoryOrganization, + FactoryOrganizationMember, + FactoryUser, + UpdateFactoryOrganizationProfileInput, +} from "@sandbox-agent/factory-shared"; +import { getActorRuntimeContext } from "../context.js"; +import { getOrCreateWorkspace } from "../handles.js"; +import { GitHubAppError } from "../../services/app-github.js"; +import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js"; +import { + appSessions, + invoices, + organizationMembers, + organizationProfile, + repos, + seatAssignments, + stripeLookup, +} from "./db/schema.js"; + +export const APP_SHELL_WORKSPACE_ID = "app"; + +const PROFILE_ROW_ID = "profile"; +const OAUTH_TTL_MS = 10 * 60_000; + +function assertAppWorkspace(c: any): void { + if (c.state.workspaceId !== APP_SHELL_WORKSPACE_ID) { + throw new Error(`App shell action requires workspace ${APP_SHELL_WORKSPACE_ID}, got ${c.state.workspaceId}`); + } +} + +function assertOrganizationWorkspace(c: any): void { + if (c.state.workspaceId === APP_SHELL_WORKSPACE_ID) { + throw new Error("Organization action cannot run on the reserved app workspace"); + } +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function personalWorkspaceId(login: string): string { + return `personal-${slugify(login)}`; +} + +function organizationWorkspaceId(kind: FactoryOrganization["kind"], login: string): string { + return kind === "personal" ? personalWorkspaceId(login) : slugify(login); +} + +function splitScopes(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function parseEligibleOrganizationIds(value: string): string[] { + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + } catch { + return []; + } +} + +function encodeEligibleOrganizationIds(value: string[]): string { + return JSON.stringify([...new Set(value)]); +} + +function encodeOauthState(payload: { sessionId: string; nonce: string }): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +function decodeOauthState(value: string): { sessionId: string; nonce: string } { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record; + if (typeof parsed.sessionId !== "string" || typeof parsed.nonce !== "string") { + throw new Error("GitHub OAuth state is malformed"); + } + return { + sessionId: parsed.sessionId, + nonce: parsed.nonce, + }; +} + +function seatsIncludedForPlan(planId: FactoryBillingPlanId): number { + switch (planId) { + case "free": + return 1; + case "team": + return 5; + } +} + +function stripeStatusToBillingStatus( + stripeStatus: string, + cancelAtPeriodEnd: boolean, +): FactoryBillingState["status"] { + if (cancelAtPeriodEnd) { + return "scheduled_cancel"; + } + if (stripeStatus === "trialing") { + return "trialing"; + } + if (stripeStatus === "past_due" || stripeStatus === "unpaid" || stripeStatus === "incomplete") { + return "past_due"; + } + return "active"; +} + +function formatUnixDate(value: number): string { + return new Date(value * 1000).toISOString().slice(0, 10); +} + +function legacyRepoImportStatusToGithubSyncStatus(value: string | null | undefined): FactoryOrganization["github"]["syncStatus"] { + switch (value) { + case "ready": + return "synced"; + case "importing": + return "syncing"; + default: + return "pending"; + } +} + +function stringFromMetadata(metadata: unknown, key: string): string | null { + if (!metadata || typeof metadata !== "object") { + return null; + } + const value = (metadata as Record)[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function stripeWebhookSubscription(event: any) { + const object = event.data.object as Record; + const items = (object.items as { data?: Array> } | undefined)?.data ?? []; + const price = items[0]?.price as Record | undefined; + return { + id: typeof object.id === "string" ? object.id : "", + customerId: typeof object.customer === "string" ? object.customer : "", + priceId: typeof price?.id === "string" ? price.id : null, + status: typeof object.status === "string" ? object.status : "active", + cancelAtPeriodEnd: object.cancel_at_period_end === true, + currentPeriodEnd: typeof object.current_period_end === "number" ? object.current_period_end : null, + trialEnd: typeof object.trial_end === "number" ? object.trial_end : null, + defaultPaymentMethodLabel: "Payment method on file", + }; +} + +async function getAppSessionRow(c: any, sessionId: string) { + assertAppWorkspace(c); + return await c.db + .select() + .from(appSessions) + .where(eq(appSessions.id, sessionId)) + .get(); +} + +async function requireAppSessionRow(c: any, sessionId: string) { + const row = await getAppSessionRow(c, sessionId); + if (!row) { + throw new Error(`Unknown app session: ${sessionId}`); + } + return row; +} + +async function ensureAppSession(c: any, requestedSessionId?: string | null): Promise { + assertAppWorkspace(c); + const requested = typeof requestedSessionId === "string" && requestedSessionId.trim().length > 0 + ? requestedSessionId.trim() + : null; + + if (requested) { + const existing = await getAppSessionRow(c, requested); + if (existing) { + return requested; + } + } + + const sessionId = requested ?? randomUUID(); + const now = Date.now(); + await c.db + .insert(appSessions) + .values({ + id: sessionId, + currentUserId: null, + currentUserName: null, + currentUserEmail: null, + currentUserGithubLogin: null, + currentUserRoleLabel: null, + eligibleOrganizationIdsJson: "[]", + activeOrganizationId: null, + githubAccessToken: null, + githubScope: "", + oauthState: null, + oauthStateExpiresAt: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + return sessionId; +} + +async function updateAppSession(c: any, sessionId: string, patch: Record): Promise { + assertAppWorkspace(c); + await c.db + .update(appSessions) + .set({ + ...patch, + updatedAt: Date.now(), + }) + .where(eq(appSessions.id, sessionId)) + .run(); +} + +async function getOrganizationState(workspace: any) { + return await workspace.getOrganizationShellState({}); +} + +async function buildAppSnapshot(c: any, sessionId: string): Promise { + assertAppWorkspace(c); + const session = await requireAppSessionRow(c, sessionId); + const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson); + + const organizations: FactoryOrganization[] = []; + for (const organizationId of eligibleOrganizationIds) { + try { + const workspace = await getOrCreateWorkspace(c, organizationId); + const organizationState = await getOrganizationState(workspace); + organizations.push(organizationState.snapshot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Actor not found")) { + throw error; + } + } + } + + const currentUser: FactoryUser | null = session.currentUserId + ? { + id: session.currentUserId, + name: session.currentUserName ?? session.currentUserGithubLogin ?? "GitHub user", + email: session.currentUserEmail ?? "", + githubLogin: session.currentUserGithubLogin ?? "", + roleLabel: session.currentUserRoleLabel ?? "GitHub user", + eligibleOrganizationIds: organizations.map((organization) => organization.id), + } + : null; + + const activeOrganizationId = + currentUser && session.activeOrganizationId && organizations.some((organization) => organization.id === session.activeOrganizationId) + ? session.activeOrganizationId + : currentUser && organizations.length === 1 + ? organizations[0]?.id ?? null + : null; + + return { + auth: { + status: currentUser ? "signed_in" : "signed_out", + currentUserId: currentUser?.id ?? null, + }, + activeOrganizationId, + users: currentUser ? [currentUser] : [], + organizations, + }; +} + +async function requireSignedInSession(c: any, sessionId: string) { + const session = await requireAppSessionRow(c, sessionId); + if (!session.currentUserId || !session.currentUserEmail || !session.currentUserGithubLogin) { + throw new Error("User must be signed in"); + } + return session; +} + +function requireEligibleOrganization(session: any, organizationId: string): void { + const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson); + if (!eligibleOrganizationIds.includes(organizationId)) { + throw new Error(`Organization ${organizationId} is not available in this app session`); + } +} + +async function upsertStripeLookupEntries( + c: any, + organizationId: string, + customerId: string | null, + subscriptionId: string | null, +): Promise { + assertAppWorkspace(c); + const now = Date.now(); + for (const lookupKey of [ + customerId ? `customer:${customerId}` : null, + subscriptionId ? `subscription:${subscriptionId}` : null, + ]) { + if (!lookupKey) { + continue; + } + await c.db + .insert(stripeLookup) + .values({ + lookupKey, + organizationId, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: stripeLookup.lookupKey, + set: { + organizationId, + updatedAt: now, + }, + }) + .run(); + } +} + +async function findOrganizationIdForStripeEvent( + c: any, + customerId: string | null, + subscriptionId: string | null, +): Promise { + assertAppWorkspace(c); + const customerLookup = customerId + ? await c.db + .select({ organizationId: stripeLookup.organizationId }) + .from(stripeLookup) + .where(eq(stripeLookup.lookupKey, `customer:${customerId}`)) + .get() + : null; + if (customerLookup?.organizationId) { + return customerLookup.organizationId; + } + + const subscriptionLookup = subscriptionId + ? await c.db + .select({ organizationId: stripeLookup.organizationId }) + .from(stripeLookup) + .where(eq(stripeLookup.lookupKey, `subscription:${subscriptionId}`)) + .get() + : null; + return subscriptionLookup?.organizationId ?? null; +} + +async function safeListOrganizations(accessToken: string): Promise { + const { appShell } = getActorRuntimeContext(); + try { + return await appShell.github.listOrganizations(accessToken); + } catch (error) { + if (error instanceof GitHubAppError && error.status === 403) { + return []; + } + throw error; + } +} + +async function safeListInstallations(accessToken: string): Promise { + const { appShell } = getActorRuntimeContext(); + try { + return await appShell.github.listInstallations(accessToken); + } catch (error) { + if (error instanceof GitHubAppError && (error.status === 403 || error.status === 404)) { + return []; + } + throw error; + } +} + +async function syncGithubSessionFromToken( + c: any, + sessionId: string, + accessToken: string, +): Promise<{ sessionId: string; redirectTo: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const session = await requireAppSessionRow(c, sessionId); + const token = { accessToken, scopes: splitScopes(session.githubScope) }; + const viewer = await appShell.github.getViewer(accessToken); + const organizations = await safeListOrganizations(accessToken); + const installations = await safeListInstallations(accessToken); + const userId = `user-${slugify(viewer.login)}`; + + const linkedOrganizationIds: string[] = []; + const accounts = [ + { + githubAccountId: viewer.id, + githubLogin: viewer.login, + githubAccountType: "User", + kind: "personal" as const, + displayName: viewer.name || viewer.login, + }, + ...organizations.map((organization) => ({ + githubAccountId: organization.id, + githubLogin: organization.login, + githubAccountType: "Organization", + kind: "organization" as const, + displayName: organization.name || organization.login, + })), + ]; + + for (const account of accounts) { + const organizationId = organizationWorkspaceId(account.kind, account.githubLogin); + const installation = installations.find((candidate) => candidate.accountLogin === account.githubLogin) ?? null; + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.syncOrganizationShellFromGithub({ + userId, + userName: viewer.name || viewer.login, + userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, + githubUserLogin: viewer.login, + githubAccountId: account.githubAccountId, + githubLogin: account.githubLogin, + githubAccountType: account.githubAccountType, + kind: account.kind, + displayName: account.displayName, + installationId: installation?.id ?? null, + appConfigured: appShell.github.isAppConfigured(), + }); + linkedOrganizationIds.push(organizationId); + } + + const activeOrganizationId = + session.activeOrganizationId && linkedOrganizationIds.includes(session.activeOrganizationId) + ? session.activeOrganizationId + : linkedOrganizationIds.length === 1 + ? linkedOrganizationIds[0] ?? null + : null; + + await updateAppSession(c, session.id, { + currentUserId: userId, + currentUserName: viewer.name || viewer.login, + currentUserEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, + currentUserGithubLogin: viewer.login, + currentUserRoleLabel: "GitHub user", + eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds), + activeOrganizationId, + githubAccessToken: accessToken, + githubScope: token.scopes.join(","), + oauthState: null, + oauthStateExpiresAt: null, + }); + + return { + sessionId: session.id, + redirectTo: `${appShell.appUrl}/organizations?factorySession=${encodeURIComponent(session.id)}`, + }; +} + +async function readOrganizationProfileRow(c: any) { + assertOrganizationWorkspace(c); + return await c.db + .select() + .from(organizationProfile) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .get(); +} + +async function requireOrganizationProfileRow(c: any) { + const row = await readOrganizationProfileRow(c); + if (!row) { + throw new Error(`Organization profile is not initialized for workspace ${c.state.workspaceId}`); + } + return row; +} + +async function listOrganizationMembers(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db + .select() + .from(organizationMembers) + .orderBy(organizationMembers.role, organizationMembers.name) + .all(); + return rows.map((row) => ({ + id: row.id, + name: row.name, + email: row.email, + role: row.role, + state: row.state, + })); +} + +async function listOrganizationSeatAssignments(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db + .select({ email: seatAssignments.email }) + .from(seatAssignments) + .orderBy(seatAssignments.email) + .all(); + return rows.map((row) => row.email); +} + +async function listOrganizationInvoices(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db + .select() + .from(invoices) + .orderBy(desc(invoices.issuedAt), desc(invoices.createdAt)) + .all(); + return rows.map((row) => ({ + id: row.id, + label: row.label, + issuedAt: row.issuedAt, + amountUsd: row.amountUsd, + status: row.status, + })); +} + +async function listOrganizationRepoCatalog(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db + .select({ remoteUrl: repos.remoteUrl }) + .from(repos) + .orderBy(desc(repos.updatedAt)) + .all(); + return rows.map((row) => repoLabelFromRemote(row.remoteUrl)).sort((left, right) => left.localeCompare(right)); +} + +async function buildOrganizationState(c: any) { + const row = await requireOrganizationProfileRow(c); + const repoCatalog = await listOrganizationRepoCatalog(c); + const members = await listOrganizationMembers(c); + const seatAssignmentEmails = await listOrganizationSeatAssignments(c); + const invoiceRows = await listOrganizationInvoices(c); + + return { + id: c.state.workspaceId, + workspaceId: c.state.workspaceId, + kind: row.kind, + githubLogin: row.githubLogin, + githubInstallationId: row.githubInstallationId ?? null, + stripeCustomerId: row.stripeCustomerId ?? null, + stripeSubscriptionId: row.stripeSubscriptionId ?? null, + stripePriceId: row.stripePriceId ?? null, + billingPlanId: row.billingPlanId, + snapshot: { + id: c.state.workspaceId, + workspaceId: c.state.workspaceId, + kind: row.kind, + settings: { + displayName: row.displayName, + slug: row.slug, + primaryDomain: row.primaryDomain, + seatAccrualMode: "first_prompt", + defaultModel: row.defaultModel, + autoImportRepos: row.autoImportRepos === 1, + }, + github: { + connectedAccount: row.githubConnectedAccount, + installationStatus: row.githubInstallationStatus, + syncStatus: row.githubSyncStatus ?? legacyRepoImportStatusToGithubSyncStatus(row.repoImportStatus), + importedRepoCount: repoCatalog.length, + lastSyncLabel: row.githubLastSyncLabel, + lastSyncAt: row.githubLastSyncAt ?? null, + }, + billing: { + planId: row.billingPlanId, + status: row.billingStatus, + seatsIncluded: row.billingSeatsIncluded, + trialEndsAt: row.billingTrialEndsAt, + renewalAt: row.billingRenewalAt, + stripeCustomerId: row.stripeCustomerId ?? "", + paymentMethodLabel: row.billingPaymentMethodLabel, + invoices: invoiceRows, + }, + members, + seatAssignments: seatAssignmentEmails, + repoCatalog, + }, + }; +} + +async function applySubscriptionState( + workspace: any, + subscription: { + id: string; + customerId: string; + priceId: string | null; + status: string; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: number | null; + trialEnd: number | null; + defaultPaymentMethodLabel: string; + }, + fallbackPlanId: FactoryBillingPlanId, +): Promise { + await workspace.applyOrganizationStripeSubscription({ + subscription, + fallbackPlanId, + }); +} + +export const workspaceAppActions = { + async ensureAppSession(c: any, input?: { requestedSessionId?: string | null }): Promise<{ sessionId: string }> { + const sessionId = await ensureAppSession(c, input?.requestedSessionId); + return { sessionId }; + }, + + async getAppSnapshot(c: any, input: { sessionId: string }): Promise { + return await buildAppSnapshot(c, input.sessionId); + }, + + async startAppGithubAuth(c: any, input: { sessionId: string }): Promise<{ url: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const sessionId = await ensureAppSession(c, input.sessionId); + const nonce = randomUUID(); + await updateAppSession(c, sessionId, { + oauthState: nonce, + oauthStateExpiresAt: Date.now() + OAUTH_TTL_MS, + }); + return { + url: appShell.github.buildAuthorizeUrl(encodeOauthState({ sessionId, nonce })), + }; + }, + + async completeAppGithubAuth(c: any, input: { code: string; state: string }): Promise<{ sessionId: string; redirectTo: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const oauth = decodeOauthState(input.state); + const session = await requireAppSessionRow(c, oauth.sessionId); + if (!session.oauthState || session.oauthState !== oauth.nonce || !session.oauthStateExpiresAt || session.oauthStateExpiresAt < Date.now()) { + throw new Error("GitHub OAuth state is invalid or expired"); + } + + const token = await appShell.github.exchangeCode(input.code); + await updateAppSession(c, session.id, { + githubScope: token.scopes.join(","), + }); + return await syncGithubSessionFromToken(c, session.id, token.accessToken); + }, + + async bootstrapAppGithubSession( + c: any, + input: { accessToken: string; sessionId?: string | null }, + ): Promise<{ sessionId: string; redirectTo: string }> { + assertAppWorkspace(c); + if (process.env.NODE_ENV === "production") { + throw new Error("bootstrapAppGithubSession is development-only"); + } + const sessionId = await ensureAppSession(c, input.sessionId ?? null); + return await syncGithubSessionFromToken(c, sessionId, input.accessToken); + }, + + async signOutApp(c: any, input: { sessionId: string }): Promise { + assertAppWorkspace(c); + const sessionId = await ensureAppSession(c, input.sessionId); + await updateAppSession(c, sessionId, { + currentUserId: null, + currentUserName: null, + currentUserEmail: null, + currentUserGithubLogin: null, + currentUserRoleLabel: null, + eligibleOrganizationIdsJson: "[]", + activeOrganizationId: null, + githubAccessToken: null, + githubScope: "", + oauthState: null, + oauthStateExpiresAt: null, + }); + return await buildAppSnapshot(c, sessionId); + }, + + async selectAppOrganization(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + await updateAppSession(c, input.sessionId, { + activeOrganizationId: input.organizationId, + }); + + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + if (organization.snapshot.github.syncStatus !== "synced") { + return await workspaceAppActions.triggerAppRepoImport(c, input); + } + return await buildAppSnapshot(c, input.sessionId); + }, + + async updateAppOrganizationProfile( + c: any, + input: { sessionId: string; organizationId: string } & UpdateFactoryOrganizationProfileInput, + ): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + await workspace.updateOrganizationShellProfile({ + displayName: input.displayName, + slug: input.slug, + primaryDomain: input.primaryDomain, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + + async triggerAppRepoImport(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + await workspace.markOrganizationSyncStarted({ + label: "Importing repository catalog...", + }); + + try { + const repositories = + organization.snapshot.kind === "personal" + ? await appShell.github.listUserRepositories(session.githubAccessToken) + : organization.githubInstallationId + ? await appShell.github.listInstallationRepositories(organization.githubInstallationId) + : (() => { + throw new GitHubAppError("GitHub App installation required before importing repositories", 400); + })(); + + await workspace.applyOrganizationSyncCompleted({ + repositories, + installationStatus: organization.snapshot.kind === "personal" ? "connected" : organization.snapshot.github.installationStatus, + lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available", + }); + } catch (error) { + const installationStatus = + error instanceof GitHubAppError && (error.status === 403 || error.status === 404) + ? "reconnect_required" + : organization.snapshot.github.installationStatus; + await workspace.markOrganizationSyncFailed({ + message: error instanceof Error ? error.message : "GitHub import failed", + installationStatus, + }); + } + + return await buildAppSnapshot(c, input.sessionId); + }, + + async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + if (organization.snapshot.kind !== "organization") { + return { + url: `${appShell.appUrl}/workspaces/${input.organizationId}?factorySession=${encodeURIComponent(input.sessionId)}`, + }; + } + return { + url: await appShell.github.buildInstallationUrl(organization.githubLogin, randomUUID()), + }; + }, + + async createAppCheckoutSession( + c: any, + input: { sessionId: string; organizationId: string; planId: FactoryBillingPlanId }, + ): Promise<{ url: string }> { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + if (input.planId === "free") { + await workspace.applyOrganizationFreePlan({ clearSubscription: false }); + return { + url: `${appShell.appUrl}/organizations/${input.organizationId}/billing?factorySession=${encodeURIComponent(input.sessionId)}`, + }; + } + + if (!appShell.stripe.isConfigured()) { + throw new Error("Stripe is not configured"); + } + + let customerId = organization.stripeCustomerId; + if (!customerId) { + customerId = ( + await appShell.stripe.createCustomer({ + organizationId: input.organizationId, + displayName: organization.snapshot.settings.displayName, + email: session.currentUserEmail, + }) + ).id; + await workspace.applyOrganizationStripeCustomer({ customerId }); + await upsertStripeLookupEntries(c, input.organizationId, customerId, null); + } + + return { + url: await appShell.stripe.createCheckoutSession({ + organizationId: input.organizationId, + customerId, + customerEmail: session.currentUserEmail, + planId: input.planId, + successUrl: `${appShell.appUrl}/api/rivet/app/billing/checkout/complete?organizationId=${encodeURIComponent( + input.organizationId, + )}&factorySession=${encodeURIComponent(input.sessionId)}&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?factorySession=${encodeURIComponent(input.sessionId)}`, + }).then((checkout) => checkout.url), + }; + }, + + async finalizeAppCheckoutSession( + c: any, + input: { sessionId: string; organizationId: string; checkoutSessionId: string }, + ): Promise<{ redirectTo: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + const completion = await appShell.stripe.retrieveCheckoutCompletion(input.checkoutSessionId); + + if (completion.customerId) { + await workspace.applyOrganizationStripeCustomer({ customerId: completion.customerId }); + } + await upsertStripeLookupEntries(c, input.organizationId, completion.customerId, completion.subscriptionId); + + if (completion.subscriptionId) { + const subscription = await appShell.stripe.retrieveSubscription(completion.subscriptionId); + await applySubscriptionState(workspace, subscription, completion.planId ?? organization.billingPlanId); + } + + if (completion.paymentMethodLabel) { + await workspace.setOrganizationBillingPaymentMethod({ + label: completion.paymentMethodLabel, + }); + } + + return { + redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing?factorySession=${encodeURIComponent(input.sessionId)}`, + }; + }, + + async createAppBillingPortalSession( + c: any, + input: { sessionId: string; organizationId: string }, + ): Promise<{ url: string }> { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + if (!organization.stripeCustomerId) { + throw new Error("Stripe customer is not available for this organization"); + } + const portal = await appShell.stripe.createPortalSession({ + customerId: organization.stripeCustomerId, + returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?factorySession=${encodeURIComponent(input.sessionId)}`, + }); + return { url: portal.url }; + }, + + async cancelAppScheduledRenewal(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + if (organization.stripeSubscriptionId && appShell.stripe.isConfigured()) { + const subscription = await appShell.stripe.updateSubscriptionCancellation(organization.stripeSubscriptionId, true); + await applySubscriptionState(workspace, subscription, organization.billingPlanId); + await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organization.stripeCustomerId, subscription.id); + } else { + await workspace.setOrganizationBillingStatus({ status: "scheduled_cancel" }); + } + + return await buildAppSnapshot(c, input.sessionId); + }, + + async resumeAppSubscription(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + if (organization.stripeSubscriptionId && appShell.stripe.isConfigured()) { + const subscription = await appShell.stripe.updateSubscriptionCancellation(organization.stripeSubscriptionId, false); + await applySubscriptionState(workspace, subscription, organization.billingPlanId); + await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organization.stripeCustomerId, subscription.id); + } else { + await workspace.setOrganizationBillingStatus({ status: "active" }); + } + + return await buildAppSnapshot(c, input.sessionId); + }, + + async recordAppSeatUsage(c: any, input: { sessionId: string; workspaceId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.workspaceId); + const workspace = await getOrCreateWorkspace(c, input.workspaceId); + await workspace.recordOrganizationSeatUsage({ + email: session.currentUserEmail, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + + async handleAppStripeWebhook(c: any, input: { payload: string; signatureHeader: string | null }): Promise<{ ok: true }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const event = appShell.stripe.verifyWebhookEvent(input.payload, input.signatureHeader); + + if (event.type === "checkout.session.completed") { + const object = event.data.object as Record; + const organizationId = + stringFromMetadata(object.metadata, "organizationId") ?? + await findOrganizationIdForStripeEvent( + c, + typeof object.customer === "string" ? object.customer : null, + typeof object.subscription === "string" ? object.subscription : null, + ); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + if (typeof object.customer === "string") { + await workspace.applyOrganizationStripeCustomer({ customerId: object.customer }); + } + await upsertStripeLookupEntries( + c, + organizationId, + typeof object.customer === "string" ? object.customer : null, + typeof object.subscription === "string" ? object.subscription : null, + ); + } + return { ok: true }; + } + + if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.created") { + const subscription = stripeWebhookSubscription(event); + const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + const organization = await getOrganizationState(workspace); + await applySubscriptionState(workspace, subscription, appShell.stripe.planIdForPriceId(subscription.priceId ?? "") ?? organization.billingPlanId); + await upsertStripeLookupEntries(c, organizationId, subscription.customerId, subscription.id); + } + return { ok: true }; + } + + if (event.type === "customer.subscription.deleted") { + const subscription = stripeWebhookSubscription(event); + const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyOrganizationFreePlan({ clearSubscription: true }); + } + return { ok: true }; + } + + if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") { + const invoice = event.data.object as Record; + const organizationId = await findOrganizationIdForStripeEvent( + c, + typeof invoice.customer === "string" ? invoice.customer : null, + null, + ); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + const rawAmount = typeof invoice.amount_paid === "number" ? invoice.amount_paid : invoice.amount_due; + const amountUsd = Math.round((typeof rawAmount === "number" ? rawAmount : 0) / 100); + await workspace.upsertOrganizationInvoice({ + id: String(invoice.id), + label: typeof invoice.number === "string" ? `Invoice ${invoice.number}` : "Stripe invoice", + issuedAt: formatUnixDate(typeof invoice.created === "number" ? invoice.created : Math.floor(Date.now() / 1000)), + amountUsd: Number.isFinite(amountUsd) ? amountUsd : 0, + status: event.type === "invoice.paid" ? "paid" : "open", + }); + } + } + + return { ok: true }; + }, + + async handleAppGithubWebhook(c: any, input: { payload: string; signatureHeader: string | null; eventHeader: string | null }): Promise<{ ok: true }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader); + + const accountLogin = body.installation?.account?.login; + const accountType = body.installation?.account?.type; + if (!accountLogin) { + console.log(`[github-webhook] Ignoring ${event}.${body.action ?? ""}: no installation account`); + return { ok: true }; + } + + const kind: FactoryOrganization["kind"] = accountType === "User" ? "personal" : "organization"; + const organizationId = organizationWorkspaceId(kind, accountLogin); + + if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) { + console.log(`[github-webhook] ${event}.${body.action} for ${accountLogin} (org=${organizationId})`); + if (body.action === "deleted") { + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyGithubInstallationRemoved({}); + } else if (body.action === "created") { + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyGithubInstallationCreated({ + installationId: body.installation?.id ?? 0, + }); + } + return { ok: true }; + } + + if (event === "installation_repositories") { + console.log(`[github-webhook] ${event}.${body.action} for ${accountLogin}: +${body.repositories_added?.length ?? 0} -${body.repositories_removed?.length ?? 0}`); + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyGithubRepositoryChanges({ + added: (body.repositories_added ?? []).map((r) => ({ + fullName: r.full_name, + private: r.private, + })), + removed: (body.repositories_removed ?? []).map((r) => r.full_name), + }); + return { ok: true }; + } + + if (event === "push" || event === "pull_request" || event === "pull_request_review" || event === "pull_request_review_comment" || + event === "check_run" || event === "check_suite" || event === "status" || event === "create" || event === "delete") { + const repoFullName = body.repository?.full_name; + if (repoFullName) { + console.log(`[github-webhook] ${event}.${body.action ?? ""} for ${repoFullName}`); + // TODO: Dispatch to GitHubStateActor / downstream actors + } + return { ok: true }; + } + + console.log(`[github-webhook] Unhandled event: ${event}.${body.action ?? ""}`); + return { ok: true }; + }, + + async syncOrganizationShellFromGithub(c: any, input: { + userId: string; + userName: string; + userEmail: string; + githubUserLogin: string; + githubAccountId: string; + githubLogin: string; + githubAccountType: string; + kind: FactoryOrganization["kind"]; + displayName: string; + installationId: number | null; + appConfigured: boolean; + }): Promise<{ organizationId: string }> { + assertOrganizationWorkspace(c); + const now = Date.now(); + const existing = await readOrganizationProfileRow(c); + const slug = existing?.slug ?? slugify(input.githubLogin); + const organizationId = organizationWorkspaceId(input.kind, input.githubLogin); + if (organizationId !== c.state.workspaceId) { + throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} github=${organizationId}`); + } + + const installationStatus = + input.kind === "personal" + ? "connected" + : input.installationId + ? "connected" + : input.appConfigured + ? "install_required" + : "reconnect_required"; + const syncStatus = existing?.githubSyncStatus ?? legacyRepoImportStatusToGithubSyncStatus(existing?.repoImportStatus); + const lastSyncLabel = + syncStatus === "synced" + ? existing.githubLastSyncLabel + : installationStatus === "connected" + ? "Waiting for first import" + : installationStatus === "install_required" + ? "GitHub App installation required" + : "GitHub App configuration incomplete"; + const hasStripeBillingState = Boolean(existing?.stripeCustomerId || existing?.stripeSubscriptionId || existing?.stripePriceId); + const defaultBillingPlanId = input.kind === "personal" || !hasStripeBillingState ? "free" : existing?.billingPlanId ?? "team"; + const defaultSeatsIncluded = input.kind === "personal" || !hasStripeBillingState ? 1 : existing?.billingSeatsIncluded ?? 5; + const defaultPaymentMethodLabel = + input.kind === "personal" + ? "No card required" + : hasStripeBillingState + ? existing?.billingPaymentMethodLabel ?? "Payment method on file" + : "No payment method on file"; + + await c.db + .insert(organizationProfile) + .values({ + id: PROFILE_ROW_ID, + kind: input.kind, + githubAccountId: input.githubAccountId, + githubLogin: input.githubLogin, + githubAccountType: input.githubAccountType, + displayName: input.displayName, + slug, + primaryDomain: existing?.primaryDomain ?? (input.kind === "personal" ? "personal" : `${slug}.github`), + defaultModel: existing?.defaultModel ?? "claude-sonnet-4", + autoImportRepos: existing?.autoImportRepos ?? 1, + repoImportStatus: existing?.repoImportStatus ?? "not_started", + githubConnectedAccount: input.githubLogin, + githubInstallationStatus: installationStatus, + githubSyncStatus: syncStatus, + githubInstallationId: input.installationId, + githubLastSyncLabel: lastSyncLabel, + githubLastSyncAt: existing?.githubLastSyncAt ?? null, + stripeCustomerId: existing?.stripeCustomerId ?? null, + stripeSubscriptionId: existing?.stripeSubscriptionId ?? null, + stripePriceId: existing?.stripePriceId ?? null, + billingPlanId: defaultBillingPlanId, + billingStatus: existing?.billingStatus ?? "active", + billingSeatsIncluded: defaultSeatsIncluded, + billingTrialEndsAt: existing?.billingTrialEndsAt ?? null, + billingRenewalAt: existing?.billingRenewalAt ?? null, + billingPaymentMethodLabel: defaultPaymentMethodLabel, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: organizationProfile.id, + set: { + kind: input.kind, + githubAccountId: input.githubAccountId, + githubLogin: input.githubLogin, + githubAccountType: input.githubAccountType, + displayName: input.displayName, + githubConnectedAccount: input.githubLogin, + githubInstallationStatus: installationStatus, + githubSyncStatus: syncStatus, + githubInstallationId: input.installationId, + githubLastSyncLabel: lastSyncLabel, + githubLastSyncAt: existing?.githubLastSyncAt ?? null, + billingPlanId: defaultBillingPlanId, + billingSeatsIncluded: defaultSeatsIncluded, + billingPaymentMethodLabel: defaultPaymentMethodLabel, + updatedAt: now, + }, + }) + .run(); + + await c.db + .insert(organizationMembers) + .values({ + id: input.userId, + name: input.userName, + email: input.userEmail, + role: input.kind === "personal" ? "owner" : "admin", + state: "active", + updatedAt: now, + }) + .onConflictDoUpdate({ + target: organizationMembers.id, + set: { + name: input.userName, + email: input.userEmail, + role: input.kind === "personal" ? "owner" : "admin", + state: "active", + updatedAt: now, + }, + }) + .run(); + + return { organizationId }; + }, + + async getOrganizationShellState(c: any): Promise { + assertOrganizationWorkspace(c); + return await buildOrganizationState(c); + }, + + async updateOrganizationShellProfile( + c: any, + input: Pick, + ): Promise { + assertOrganizationWorkspace(c); + const existing = await requireOrganizationProfileRow(c); + await c.db + .update(organizationProfile) + .set({ + displayName: input.displayName.trim() || existing.displayName, + slug: input.slug.trim() || existing.slug, + primaryDomain: input.primaryDomain.trim() || existing.primaryDomain, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async markOrganizationSyncStarted(c: any, input: { label: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubSyncStatus: "syncing", + githubLastSyncLabel: input.label, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationSyncCompleted( + c: any, + input: { + repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>; + installationStatus: FactoryOrganization["github"]["installationStatus"]; + lastSyncLabel: string; + }, + ): Promise { + assertOrganizationWorkspace(c); + const now = Date.now(); + for (const repository of input.repositories) { + const remoteUrl = repository.cloneUrl; + await c.db + .insert(repos) + .values({ + repoId: repoIdFromRemote(remoteUrl), + remoteUrl, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: repos.repoId, + set: { + remoteUrl, + updatedAt: now, + }, + }) + .run(); + } + await c.db + .update(organizationProfile) + .set({ + githubInstallationStatus: input.installationStatus, + githubSyncStatus: "synced", + githubLastSyncLabel: input.lastSyncLabel, + githubLastSyncAt: now, + updatedAt: now, + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async markOrganizationSyncFailed( + c: any, + input: { message: string; installationStatus: FactoryOrganization["github"]["installationStatus"] }, + ): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubInstallationStatus: input.installationStatus, + githubSyncStatus: "error", + githubLastSyncLabel: input.message, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationStripeCustomer(c: any, input: { customerId: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + stripeCustomerId: input.customerId, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationStripeSubscription( + c: any, + input: { + subscription: { + id: string; + customerId: string; + priceId: string | null; + status: string; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: number | null; + trialEnd: number | null; + defaultPaymentMethodLabel: string; + }; + fallbackPlanId: FactoryBillingPlanId; + }, + ): Promise { + assertOrganizationWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const planId = appShell.stripe.planIdForPriceId(input.subscription.priceId ?? "") ?? input.fallbackPlanId; + await c.db + .update(organizationProfile) + .set({ + stripeCustomerId: input.subscription.customerId || null, + stripeSubscriptionId: input.subscription.id || null, + stripePriceId: input.subscription.priceId, + billingPlanId: planId, + billingStatus: stripeStatusToBillingStatus(input.subscription.status, input.subscription.cancelAtPeriodEnd), + billingSeatsIncluded: seatsIncludedForPlan(planId), + billingTrialEndsAt: input.subscription.trialEnd ? new Date(input.subscription.trialEnd * 1000).toISOString() : null, + billingRenewalAt: input.subscription.currentPeriodEnd ? new Date(input.subscription.currentPeriodEnd * 1000).toISOString() : null, + billingPaymentMethodLabel: input.subscription.defaultPaymentMethodLabel || "Payment method on file", + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationFreePlan(c: any, input: { clearSubscription: boolean }): Promise { + assertOrganizationWorkspace(c); + const patch: Record = { + billingPlanId: "free", + billingStatus: "active", + billingSeatsIncluded: 1, + billingTrialEndsAt: null, + billingRenewalAt: null, + billingPaymentMethodLabel: "No card required", + updatedAt: Date.now(), + }; + if (input.clearSubscription) { + patch.stripeSubscriptionId = null; + patch.stripePriceId = null; + } + await c.db + .update(organizationProfile) + .set(patch) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async setOrganizationBillingPaymentMethod(c: any, input: { label: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + billingPaymentMethodLabel: input.label, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async setOrganizationBillingStatus(c: any, input: { status: FactoryBillingState["status"] }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + billingStatus: input.status, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async upsertOrganizationInvoice( + c: any, + input: { id: string; label: string; issuedAt: string; amountUsd: number; status: "paid" | "open" }, + ): Promise { + assertOrganizationWorkspace(c); + await c.db + .insert(invoices) + .values({ + id: input.id, + label: input.label, + issuedAt: input.issuedAt, + amountUsd: input.amountUsd, + status: input.status, + createdAt: Date.now(), + }) + .onConflictDoUpdate({ + target: invoices.id, + set: { + label: input.label, + issuedAt: input.issuedAt, + amountUsd: input.amountUsd, + status: input.status, + }, + }) + .run(); + }, + + async recordOrganizationSeatUsage(c: any, input: { email: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .insert(seatAssignments) + .values({ + email: input.email, + createdAt: Date.now(), + }) + .onConflictDoNothing() + .run(); + }, + + async applyGithubInstallationCreated(c: any, input: { installationId: number }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubInstallationId: input.installationId, + githubInstallationStatus: "connected", + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyGithubInstallationRemoved(c: any, _input: {}): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubInstallationId: null, + githubInstallationStatus: "install_required", + githubSyncStatus: "pending", + githubLastSyncLabel: "GitHub App installation removed", + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyGithubRepositoryChanges(c: any, input: { added: Array<{ fullName: string; private: boolean }>; removed: string[] }): Promise { + assertOrganizationWorkspace(c); + const now = Date.now(); + + for (const repo of input.added) { + const remoteUrl = `https://github.com/${repo.fullName}.git`; + const repoId = repoIdFromRemote(remoteUrl); + await c.db + .insert(repos) + .values({ + repoId, + remoteUrl, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: repos.repoId, + set: { + remoteUrl, + updatedAt: now, + }, + }) + .run(); + } + + for (const fullName of input.removed) { + const remoteUrl = `https://github.com/${fullName}.git`; + const repoId = repoIdFromRemote(remoteUrl); + await c.db.delete(repos).where(eq(repos.repoId, repoId)).run(); + } + + const repoCount = (await c.db.select().from(repos).all()).length; + await c.db + .update(organizationProfile) + .set({ + githubSyncStatus: "synced", + githubLastSyncLabel: `${repoCount} repositories synced`, + githubLastSyncAt: now, + updatedAt: now, + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, +}; diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql b/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql index 9e7428d..d23981f 100644 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql +++ b/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql @@ -1,4 +1,4 @@ -CREATE TABLE `handoff_lookup` ( +CREATE TABLE `task_lookup` ( `handoff_id` text PRIMARY KEY NOT NULL, `repo_id` text NOT NULL ); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0009_github_sync_status.sql b/factory/packages/backend/src/actors/workspace/db/drizzle/0009_github_sync_status.sql new file mode 100644 index 0000000..32be96e --- /dev/null +++ b/factory/packages/backend/src/actors/workspace/db/drizzle/0009_github_sync_status.sql @@ -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; diff --git a/factory/packages/backend/src/actors/workspace/db/migrations.ts b/factory/packages/backend/src/actors/workspace/db/migrations.ts index 58e3ed5..585e2b7 100644 --- a/factory/packages/backend/src/actors/workspace/db/migrations.ts +++ b/factory/packages/backend/src/actors/workspace/db/migrations.ts @@ -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 }; diff --git a/factory/packages/backend/src/actors/workspace/db/schema.ts b/factory/packages/backend/src/actors/workspace/db/schema.ts index bd35fb2..1b3442d 100644 --- a/factory/packages/backend/src/actors/workspace/db/schema.ts +++ b/factory/packages/backend/src/actors/workspace/db/schema.ts @@ -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(), +}); diff --git a/factory/packages/backend/src/index.ts b/factory/packages/backend/src/index.ts index ed1752d..fb4a1f3 100644 --- a/factory/packages/backend/src/index.ts +++ b/factory/packages/backend/src/index.ts @@ -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 { + process.env.NODE_ENV ||= "development"; loadDevelopmentEnvFiles(); applyDevelopmentEnvDefaults(); @@ -50,36 +52,15 @@ export async function startBackend(options: BackendStartOptions = {}): Promise => { - 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 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 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 => { 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 { - 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 { 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/") diff --git a/factory/packages/backend/src/integrations/sandbox-agent/client.ts b/factory/packages/backend/src/integrations/sandbox-agent/client.ts index dda199f..8b0bb90 100644 --- a/factory/packages/backend/src/integrations/sandbox-agent/client.ts +++ b/factory/packages/backend/src/integrations/sandbox-agent/client.ts @@ -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(() => { diff --git a/factory/packages/backend/src/notifications/index.ts b/factory/packages/backend/src/notifications/index.ts index 292c519..91d443f 100644 --- a/factory/packages/backend/src/notifications/index.ts +++ b/factory/packages/backend/src/notifications/index.ts @@ -12,7 +12,7 @@ export interface NotificationService { prApproved(branchName: string, prNumber: number, reviewer: string): Promise; changesRequested(branchName: string, prNumber: number, reviewer: string): Promise; prMerged(branchName: string, prNumber: number): Promise; - handoffCreated(branchName: string): Promise; + taskCreated(branchName: string): Promise; } 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 { - await notify("Handoff Created", `New handoff on ${branchName}`, "low"); + async taskCreated(branchName: string): Promise { + await notify("Task Created", `New task on ${branchName}`, "low"); }, }; } diff --git a/factory/packages/backend/src/providers/daytona/index.ts b/factory/packages/backend/src/providers/daytona/index.ts index 471552a..bd3d568 100644 --- a/factory/packages/backend/src/providers/daytona/index.ts +++ b/factory/packages/backend/src/providers/daytona/index.ts @@ -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 { diff --git a/factory/packages/backend/src/providers/local/index.ts b/factory/packages/backend/src/providers/local/index.ts index c869f6a..9662049 100644 --- a/factory/packages/backend/src/providers/local/index.ts +++ b/factory/packages/backend/src/providers/local/index.ts @@ -160,7 +160,7 @@ export class LocalProvider implements SandboxProvider { } async createSandbox(req: CreateSandboxRequest): Promise { - 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); diff --git a/factory/packages/backend/src/providers/provider-api/index.ts b/factory/packages/backend/src/providers/provider-api/index.ts index 67a9af1..ee5a383 100644 --- a/factory/packages/backend/src/providers/provider-api/index.ts +++ b/factory/packages/backend/src/providers/provider-api/index.ts @@ -10,7 +10,7 @@ export interface CreateSandboxRequest { repoId: string; repoRemote: string; branchName: string; - handoffId: string; + taskId: string; debug?: (message: string, context?: Record) => void; options?: Record; } diff --git a/factory/packages/backend/src/services/app-github.ts b/factory/packages/backend/src/services/app-github.ts new file mode 100644 index 0000000..8b25740 --- /dev/null +++ b/factory/packages/backend/src/services/app-github.ts @@ -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 { + 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 { + 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 { + 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>( + "/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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(path: string): Promise { + 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(path: string): Promise { + let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; + const items: T[] = []; + + while (nextUrl) { + const page = await this.requestAppPage(nextUrl); + items.push(...page.items); + nextUrl = page.nextUrl ?? ""; + } + + return items; + } + + private async requestJson(path: string, accessToken: string): Promise { + 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(path: string, accessToken: string): Promise { + let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; + const items: T[] = []; + + while (nextUrl) { + const page = await this.requestPage(nextUrl, accessToken); + items.push(...page.items); + nextUrl = page.nextUrl ?? ""; + } + + return items; + } + + private async requestPage(url: string, accessToken: string): Promise> { + 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(url: string): Promise> { + 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, ""); +} diff --git a/factory/packages/backend/src/services/app-shell-runtime.ts b/factory/packages/backend/src/services/app-shell-runtime.ts new file mode 100644 index 0000000..40fd89f --- /dev/null +++ b/factory/packages/backend/src/services/app-shell-runtime.ts @@ -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, +}; diff --git a/factory/packages/backend/src/services/app-state.ts b/factory/packages/backend/src/services/app-state.ts deleted file mode 100644 index 252b02a..0000000 --- a/factory/packages/backend/src/services/app-state.ts +++ /dev/null @@ -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; -} - -export class FactoryAppStore { - private readonly filePath: string; - private readonly onOrganizationReposReady?: (organization: FactoryOrganization) => Promise; - private state: PersistedFactoryAppState; - private readonly importTimers = new Map>(); - - 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 { - 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 { - 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)); - } -} diff --git a/factory/packages/backend/src/services/app-stripe.ts b/factory/packages/backend/src/services/app-stripe.ts new file mode 100644 index 0000000..936c64d --- /dev/null +++ b/factory/packages/backend/src/services/app-stripe.ts @@ -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 { + 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; + successUrl: string; + cancelUrl: string; + }): Promise { + const priceId = this.priceIdForPlan(input.planId); + return this.formRequest("/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 { + return this.formRequest("/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 { + const payload = await this.formRequest>(`/v1/subscriptions/${subscriptionId}`, { + cancel_at_period_end: cancelAtPeriodEnd ? "true" : "false", + }); + return stripeSubscriptionSnapshot(payload); + } + + async retrieveCheckoutCompletion(sessionId: string): Promise { + const payload = await this.requestJson>( + `/v1/checkout/sessions/${sessionId}?expand[]=subscription.default_payment_method`, + ); + + const subscription = + typeof payload.subscription === "object" && payload.subscription ? (payload.subscription as Record) : 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 { + const payload = await this.requestJson>( + `/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): string { + const priceId = this.teamPriceId; + if (!priceId) { + throw new StripeAppError(`Stripe price ID is not configured for ${planId}`, 500); + } + return priceId; + } + + private async requestJson(path: string): Promise { + 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(path: string, body: Record): Promise { + 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).planId; + return planId === "team" || planId === "free" ? planId : null; +} + +function firstStripePriceId(subscription: Record | null): string | null { + if (!subscription || typeof subscription.items !== "object" || !subscription.items) { + return null; + } + const data = (subscription.items as { data?: Array> }).data; + const first = data?.[0]; + if (!first || typeof first.price !== "object" || !first.price) { + return null; + } + return typeof (first.price as Record).id === "string" + ? ((first.price as Record).id as string) + : null; +} + +function paymentMethodLabelFromObject(paymentMethod: unknown): string { + if (!paymentMethod || typeof paymentMethod !== "object") { + return "Card on file"; + } + const card = (paymentMethod as Record).card; + if (card && typeof card === "object") { + const brand = typeof (card as Record).brand === "string" ? ((card as Record).brand as string) : "Card"; + const last4 = typeof (card as Record).last4 === "string" ? ((card as Record).last4 as string) : "file"; + return `${capitalize(brand)} ending in ${last4}`; + } + return "Payment method on file"; +} + +function stripeSubscriptionSnapshot(payload: Record): 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; +} diff --git a/factory/packages/backend/src/services/create-flow.ts b/factory/packages/backend/src/services/create-flow.ts index 4589e2f..ac568e4 100644 --- a/factory/packages/backend/src/services/create-flow.ts +++ b/factory/packages/backend/src/services/create-flow.ts @@ -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( diff --git a/factory/packages/backend/test/app-state.test.ts b/factory/packages/backend/test/app-state.test.ts new file mode 100644 index 0000000..ffe207a --- /dev/null +++ b/factory/packages/backend/test/app-state.test.ts @@ -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) { + 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) { + 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", + }); + }); +}); diff --git a/factory/packages/backend/test/config-env.test.ts b/factory/packages/backend/test/config-env.test.ts index ae5ca26..8633ffd 100644 --- a/factory/packages/backend/test/config-env.test.ts +++ b/factory/packages/backend/test/config-env.test.ts @@ -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( @@ -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"); + }); }); diff --git a/factory/packages/backend/test/create-flow.test.ts b/factory/packages/backend/test/create-flow.test.ts index 11ec145..66e0eaf 100644 --- a/factory/packages/backend/test/create-flow.test.ts +++ b/factory/packages/backend/test/create-flow.test.ts @@ -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"); }); diff --git a/factory/packages/backend/test/daytona-provider.test.ts b/factory/packages/backend/test/daytona-provider.test.ts index 025f0f0..21fc014 100644 --- a/factory/packages/backend/test/daytona-provider.test.ts +++ b/factory/packages/backend/test/daytona-provider.test.ts @@ -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) { diff --git a/factory/packages/backend/test/helpers/test-context.ts b/factory/packages/backend/test/helpers/test-context.ts index b163905..cadc743 100644 --- a/factory/packages/backend/test/helpers/test-context.ts +++ b/factory/packages/backend/test/helpers/test-context.ts @@ -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 { return ConfigSchema.parse({ @@ -31,10 +32,21 @@ export function createTestConfig(overrides?: Partial): AppConfig { export function createTestRuntimeContext( driver: BackendDriver, - configOverrides?: Partial + configOverrides?: Partial, + appShellOverrides?: Partial ): { 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 }; } diff --git a/factory/packages/backend/test/keys.test.ts b/factory/packages/backend/test/keys.test.ts index d2f52f8..c2bbf9d 100644 --- a/factory/packages/backend/test/keys.test.ts +++ b/factory/packages/backend/test/keys.test.ts @@ -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) { diff --git a/factory/packages/backend/test/providers.test.ts b/factory/packages/backend/test/providers.test.ts index a86f7d1..839cc61 100644 --- a/factory/packages/backend/test/providers.test.ts +++ b/factory/packages/backend/test/providers.test.ts @@ -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, diff --git a/factory/packages/backend/test/stack-model.test.ts b/factory/packages/backend/test/stack-model.test.ts index 35b5df5..0fc0e0b 100644 --- a/factory/packages/backend/test/stack-model.test.ts +++ b/factory/packages/backend/test/stack-model.test.ts @@ -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", () => { diff --git a/factory/packages/backend/test/workbench-unread.test.ts b/factory/packages/backend/test/workbench-unread.test.ts index 28a05d9..f7ed201 100644 --- a/factory/packages/backend/test/workbench-unread.test.ts +++ b/factory/packages/backend/test/workbench-unread.test.ts @@ -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", () => { diff --git a/factory/packages/backend/test/workspace-isolation.test.ts b/factory/packages/backend/test/workspace-isolation.test.ts index bf3a22b..12eb925 100644 --- a/factory/packages/backend/test/workspace-isolation.test.ts +++ b/factory/packages/backend/test/workspace-isolation.test.ts @@ -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); } ); }); diff --git a/factory/packages/cli/package.json b/factory/packages/cli/package.json deleted file mode 100644 index 87ef87c..0000000 --- a/factory/packages/cli/package.json +++ /dev/null @@ -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" - } -} diff --git a/factory/packages/cli/src/backend/manager.ts b/factory/packages/cli/src/backend/manager.ts deleted file mode 100644 index 0bd800b..0000000 --- a/factory/packages/cli/src/backend/manager.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; -} diff --git a/factory/packages/cli/src/build-id.ts b/factory/packages/cli/src/build-id.ts deleted file mode 100644 index 1f952af..0000000 --- a/factory/packages/cli/src/build-id.ts +++ /dev/null @@ -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"; - diff --git a/factory/packages/cli/src/index.ts b/factory/packages/cli/src/index.ts deleted file mode 100644 index c4dbb62..0000000 --- a/factory/packages/cli/src/index.ts +++ /dev/null @@ -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 { - 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, workspaceId: string): Promise { - 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 - hf tui [--workspace WS] - - hf create [task] [--workspace WS] --repo [--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 [--workspace WS] - hf merge [--workspace WS] - hf archive [--workspace WS] - hf push [--workspace WS] - hf sync [--workspace WS] - hf kill [--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= - backend running= pid= version= - handoffs total= - status queued= running= idle= archived= killed= error= - providers = ... - 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: - \t\t\t - \t\t\t - 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 { - 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 { - const sub = args[0]; - if (sub !== "use") { - throw new Error("Usage: hf workspace use "); - } - - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - workspaceId: string, - handoffId: string, - timeoutMs: number -): Promise { - 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 { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - - const repoRemote = readOption(args, "--repo"); - if (!repoRemote) { - throw new Error("Missing required --repo "); - } - 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 { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - await runTuiCommand(config, workspaceId); -} - -async function handleStatus(args: string[]): Promise { - 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 { - 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 { - 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 { - 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); -}); diff --git a/factory/packages/cli/src/task-editor.ts b/factory/packages/cli/src/task-editor.ts deleted file mode 100644 index 3c94367..0000000 --- a/factory/packages/cli/src/task-editor.ts +++ /dev/null @@ -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 }); - } -} diff --git a/factory/packages/cli/src/theme.ts b/factory/packages/cli/src/theme.ts deleted file mode 100644 index 32232aa..0000000 --- a/factory/packages/cli/src/theme.ts +++ /dev/null @@ -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; - -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; - -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 | 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 = {}; - 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); -} diff --git a/factory/packages/cli/src/themes/opencode-pack.json b/factory/packages/cli/src/themes/opencode-pack.json deleted file mode 100644 index 391bca1..0000000 --- a/factory/packages/cli/src/themes/opencode-pack.json +++ /dev/null @@ -1,7408 +0,0 @@ -{ - "aura": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0f0f0f", - "darkBgPanel": "#15141b", - "darkBorder": "#2d2d2d", - "darkFgMuted": "#6d6d6d", - "darkFg": "#edecee", - "purple": "#a277ff", - "pink": "#f694ff", - "blue": "#82e2ff", - "red": "#ff6767", - "orange": "#ffca85", - "cyan": "#61ffca", - "green": "#9dff65" - }, - "theme": { - "primary": "purple", - "secondary": "pink", - "accent": "purple", - "error": "red", - "warning": "orange", - "success": "cyan", - "info": "purple", - "text": "darkFg", - "textMuted": "darkFgMuted", - "background": "darkBg", - "backgroundPanel": "darkBgPanel", - "backgroundElement": "darkBgPanel", - "border": "darkBorder", - "borderActive": "darkFgMuted", - "borderSubtle": "darkBorder", - "diffAdded": "cyan", - "diffRemoved": "red", - "diffContext": "darkFgMuted", - "diffHunkHeader": "darkFgMuted", - "diffHighlightAdded": "cyan", - "diffHighlightRemoved": "red", - "diffAddedBg": "#354933", - "diffRemovedBg": "#3f191a", - "diffContextBg": "darkBgPanel", - "diffLineNumber": "darkBorder", - "diffAddedLineNumberBg": "#162620", - "diffRemovedLineNumberBg": "#26161a", - "markdownText": "darkFg", - "markdownHeading": "purple", - "markdownLink": "pink", - "markdownLinkText": "purple", - "markdownCode": "cyan", - "markdownBlockQuote": "darkFgMuted", - "markdownEmph": "orange", - "markdownStrong": "purple", - "markdownHorizontalRule": "darkFgMuted", - "markdownListItem": "purple", - "markdownListEnumeration": "purple", - "markdownImage": "pink", - "markdownImageText": "purple", - "markdownCodeBlock": "darkFg", - "syntaxComment": "darkFgMuted", - "syntaxKeyword": "pink", - "syntaxFunction": "purple", - "syntaxVariable": "purple", - "syntaxString": "cyan", - "syntaxNumber": "green", - "syntaxType": "purple", - "syntaxOperator": "pink", - "syntaxPunctuation": "darkFg" - } - }, - "ayu": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0B0E14", - "darkBgAlt": "#0D1017", - "darkLine": "#11151C", - "darkPanel": "#0F131A", - "darkFg": "#BFBDB6", - "darkFgMuted": "#565B66", - "darkGutter": "#6C7380", - "darkTag": "#39BAE6", - "darkFunc": "#FFB454", - "darkEntity": "#59C2FF", - "darkString": "#AAD94C", - "darkRegexp": "#95E6CB", - "darkMarkup": "#F07178", - "darkKeyword": "#FF8F40", - "darkSpecial": "#E6B673", - "darkComment": "#ACB6BF", - "darkConstant": "#D2A6FF", - "darkOperator": "#F29668", - "darkAdded": "#7FD962", - "darkRemoved": "#F26D78", - "darkAccent": "#E6B450", - "darkError": "#D95757", - "darkIndentActive": "#6C7380" - }, - "theme": { - "primary": "darkEntity", - "secondary": "darkConstant", - "accent": "darkAccent", - "error": "darkError", - "warning": "darkSpecial", - "success": "darkAdded", - "info": "darkTag", - "text": "darkFg", - "textMuted": "darkFgMuted", - "background": "darkBg", - "backgroundPanel": "darkPanel", - "backgroundElement": "darkBgAlt", - "border": "darkGutter", - "borderActive": "darkIndentActive", - "borderSubtle": "darkLine", - "diffAdded": "darkAdded", - "diffRemoved": "darkRemoved", - "diffContext": "darkComment", - "diffHunkHeader": "darkComment", - "diffHighlightAdded": "darkString", - "diffHighlightRemoved": "darkMarkup", - "diffAddedBg": "#20303b", - "diffRemovedBg": "#37222c", - "diffContextBg": "darkPanel", - "diffLineNumber": "darkGutter", - "diffAddedLineNumberBg": "#1b2b34", - "diffRemovedLineNumberBg": "#2d1f26", - "markdownText": "darkFg", - "markdownHeading": "darkConstant", - "markdownLink": "darkEntity", - "markdownLinkText": "darkTag", - "markdownCode": "darkString", - "markdownBlockQuote": "darkSpecial", - "markdownEmph": "darkSpecial", - "markdownStrong": "darkFunc", - "markdownHorizontalRule": "darkFgMuted", - "markdownListItem": "darkEntity", - "markdownListEnumeration": "darkTag", - "markdownImage": "darkEntity", - "markdownImageText": "darkTag", - "markdownCodeBlock": "darkFg", - "syntaxComment": "darkComment", - "syntaxKeyword": "darkKeyword", - "syntaxFunction": "darkFunc", - "syntaxVariable": "darkEntity", - "syntaxString": "darkString", - "syntaxNumber": "darkConstant", - "syntaxType": "darkSpecial", - "syntaxOperator": "darkOperator", - "syntaxPunctuation": "darkFg" - } - }, - "carbonfox": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "bg0": "#0d0d0d", - "bg1": "#161616", - "bg1a": "#1a1a1a", - "bg2": "#1e1e1e", - "bg3": "#262626", - "bg4": "#303030", - "fg0": "#ffffff", - "fg1": "#f2f4f8", - "fg2": "#a9afbc", - "fg3": "#7d848f", - "lbg0": "#ffffff", - "lbg1": "#f4f4f4", - "lbg2": "#e8e8e8", - "lbg3": "#dcdcdc", - "lfg0": "#000000", - "lfg1": "#161616", - "lfg2": "#525252", - "lfg3": "#6f6f6f", - "red": "#ee5396", - "green": "#25be6a", - "yellow": "#08bdba", - "blue": "#78a9ff", - "magenta": "#be95ff", - "cyan": "#33b1ff", - "white": "#dfdfe0", - "orange": "#3ddbd9", - "pink": "#ff7eb6", - "blueBright": "#8cb6ff", - "cyanBright": "#52c7ff", - "greenBright": "#46c880", - "redLight": "#9f1853", - "greenLight": "#198038", - "yellowLight": "#007d79", - "blueLight": "#0043ce", - "magentaLight": "#6929c4", - "cyanLight": "#0072c3", - "warning": "#f1c21b", - "diffGreen": "#50fa7b", - "diffRed": "#ff6b6b", - "diffGreenBg": "#0f2418", - "diffRedBg": "#2a1216" - }, - "theme": { - "primary": { - "dark": "cyan", - "light": "blueLight" - }, - "secondary": { - "dark": "blue", - "light": "blueLight" - }, - "accent": { - "dark": "pink", - "light": "redLight" - }, - "error": { - "dark": "red", - "light": "redLight" - }, - "warning": { - "dark": "warning", - "light": "yellowLight" - }, - "success": { - "dark": "green", - "light": "greenLight" - }, - "info": { - "dark": "blue", - "light": "blueLight" - }, - "text": { - "dark": "fg1", - "light": "lfg1" - }, - "textMuted": { - "dark": "fg3", - "light": "lfg3" - }, - "background": { - "dark": "bg1", - "light": "lbg0" - }, - "backgroundPanel": { - "dark": "bg1a", - "light": "lbg1" - }, - "backgroundElement": { - "dark": "bg2", - "light": "lbg1" - }, - "border": { - "dark": "bg4", - "light": "lbg3" - }, - "borderActive": { - "dark": "cyan", - "light": "blueLight" - }, - "borderSubtle": { - "dark": "bg3", - "light": "lbg2" - }, - "diffAdded": { - "dark": "diffGreen", - "light": "greenLight" - }, - "diffRemoved": { - "dark": "diffRed", - "light": "redLight" - }, - "diffContext": { - "dark": "fg3", - "light": "lfg3" - }, - "diffHunkHeader": { - "dark": "blue", - "light": "blueLight" - }, - "diffHighlightAdded": { - "dark": "#7dffaa", - "light": "greenLight" - }, - "diffHighlightRemoved": { - "dark": "#ff9999", - "light": "redLight" - }, - "diffAddedBg": { - "dark": "diffGreenBg", - "light": "#defbe6" - }, - "diffRemovedBg": { - "dark": "diffRedBg", - "light": "#fff1f1" - }, - "diffContextBg": { - "dark": "bg1", - "light": "lbg1" - }, - "diffLineNumber": { - "dark": "fg3", - "light": "lfg3" - }, - "diffAddedLineNumberBg": { - "dark": "diffGreenBg", - "light": "#defbe6" - }, - "diffRemovedLineNumberBg": { - "dark": "diffRedBg", - "light": "#fff1f1" - }, - "markdownText": { - "dark": "fg1", - "light": "lfg1" - }, - "markdownHeading": { - "dark": "blueBright", - "light": "blueLight" - }, - "markdownLink": { - "dark": "blue", - "light": "blueLight" - }, - "markdownLinkText": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownCode": { - "dark": "green", - "light": "greenLight" - }, - "markdownBlockQuote": { - "dark": "fg3", - "light": "lfg3" - }, - "markdownEmph": { - "dark": "magenta", - "light": "magentaLight" - }, - "markdownStrong": { - "dark": "fg0", - "light": "lfg0" - }, - "markdownHorizontalRule": { - "dark": "bg4", - "light": "lbg3" - }, - "markdownListItem": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownImage": { - "dark": "blue", - "light": "blueLight" - }, - "markdownImageText": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownCodeBlock": { - "dark": "fg2", - "light": "lfg2" - }, - "syntaxComment": { - "dark": "fg3", - "light": "lfg3" - }, - "syntaxKeyword": { - "dark": "magenta", - "light": "magentaLight" - }, - "syntaxFunction": { - "dark": "blueBright", - "light": "blueLight" - }, - "syntaxVariable": { - "dark": "white", - "light": "lfg1" - }, - "syntaxString": { - "dark": "green", - "light": "greenLight" - }, - "syntaxNumber": { - "dark": "orange", - "light": "yellowLight" - }, - "syntaxType": { - "dark": "yellow", - "light": "yellowLight" - }, - "syntaxOperator": { - "dark": "fg2", - "light": "lfg2" - }, - "syntaxPunctuation": { - "dark": "fg2", - "light": "lfg1" - } - } - }, - "catppuccin-frappe": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "frappeRosewater": "#f2d5cf", - "frappeFlamingo": "#eebebe", - "frappePink": "#f4b8e4", - "frappeMauve": "#ca9ee6", - "frappeRed": "#e78284", - "frappeMaroon": "#ea999c", - "frappePeach": "#ef9f76", - "frappeYellow": "#e5c890", - "frappeGreen": "#a6d189", - "frappeTeal": "#81c8be", - "frappeSky": "#99d1db", - "frappeSapphire": "#85c1dc", - "frappeBlue": "#8da4e2", - "frappeLavender": "#babbf1", - "frappeText": "#c6d0f5", - "frappeSubtext1": "#b5bfe2", - "frappeSubtext0": "#a5adce", - "frappeOverlay2": "#949cb8", - "frappeOverlay1": "#838ba7", - "frappeOverlay0": "#737994", - "frappeSurface2": "#626880", - "frappeSurface1": "#51576d", - "frappeSurface0": "#414559", - "frappeBase": "#303446", - "frappeMantle": "#292c3c", - "frappeCrust": "#232634" - }, - "theme": { - "primary": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "secondary": { - "dark": "frappeMauve", - "light": "frappeMauve" - }, - "accent": { - "dark": "frappePink", - "light": "frappePink" - }, - "error": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "warning": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "success": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "info": { - "dark": "frappeTeal", - "light": "frappeTeal" - }, - "text": { - "dark": "frappeText", - "light": "frappeText" - }, - "textMuted": { - "dark": "frappeSubtext1", - "light": "frappeSubtext1" - }, - "background": { - "dark": "frappeBase", - "light": "frappeBase" - }, - "backgroundPanel": { - "dark": "frappeMantle", - "light": "frappeMantle" - }, - "backgroundElement": { - "dark": "frappeCrust", - "light": "frappeCrust" - }, - "border": { - "dark": "frappeSurface0", - "light": "frappeSurface0" - }, - "borderActive": { - "dark": "frappeSurface1", - "light": "frappeSurface1" - }, - "borderSubtle": { - "dark": "frappeSurface2", - "light": "frappeSurface2" - }, - "diffAdded": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "diffRemoved": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "diffContext": { - "dark": "frappeOverlay2", - "light": "frappeOverlay2" - }, - "diffHunkHeader": { - "dark": "frappePeach", - "light": "frappePeach" - }, - "diffHighlightAdded": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "diffHighlightRemoved": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "diffAddedBg": { - "dark": "#29342b", - "light": "#29342b" - }, - "diffRemovedBg": { - "dark": "#3a2a31", - "light": "#3a2a31" - }, - "diffContextBg": { - "dark": "frappeMantle", - "light": "frappeMantle" - }, - "diffLineNumber": { - "dark": "frappeSurface1", - "light": "frappeSurface1" - }, - "diffAddedLineNumberBg": { - "dark": "#223025", - "light": "#223025" - }, - "diffRemovedLineNumberBg": { - "dark": "#2f242b", - "light": "#2f242b" - }, - "markdownText": { - "dark": "frappeText", - "light": "frappeText" - }, - "markdownHeading": { - "dark": "frappeMauve", - "light": "frappeMauve" - }, - "markdownLink": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "markdownLinkText": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "markdownCode": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "markdownBlockQuote": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "markdownEmph": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "markdownStrong": { - "dark": "frappePeach", - "light": "frappePeach" - }, - "markdownHorizontalRule": { - "dark": "frappeSubtext0", - "light": "frappeSubtext0" - }, - "markdownListItem": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "markdownListEnumeration": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "markdownImage": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "markdownImageText": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "markdownCodeBlock": { - "dark": "frappeText", - "light": "frappeText" - }, - "syntaxComment": { - "dark": "frappeOverlay2", - "light": "frappeOverlay2" - }, - "syntaxKeyword": { - "dark": "frappeMauve", - "light": "frappeMauve" - }, - "syntaxFunction": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "syntaxVariable": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "syntaxString": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "syntaxNumber": { - "dark": "frappePeach", - "light": "frappePeach" - }, - "syntaxType": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "syntaxOperator": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "syntaxPunctuation": { - "dark": "frappeText", - "light": "frappeText" - } - } - }, - "catppuccin-macchiato": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "macRosewater": "#f4dbd6", - "macFlamingo": "#f0c6c6", - "macPink": "#f5bde6", - "macMauve": "#c6a0f6", - "macRed": "#ed8796", - "macMaroon": "#ee99a0", - "macPeach": "#f5a97f", - "macYellow": "#eed49f", - "macGreen": "#a6da95", - "macTeal": "#8bd5ca", - "macSky": "#91d7e3", - "macSapphire": "#7dc4e4", - "macBlue": "#8aadf4", - "macLavender": "#b7bdf8", - "macText": "#cad3f5", - "macSubtext1": "#b8c0e0", - "macSubtext0": "#a5adcb", - "macOverlay2": "#939ab7", - "macOverlay1": "#8087a2", - "macOverlay0": "#6e738d", - "macSurface2": "#5b6078", - "macSurface1": "#494d64", - "macSurface0": "#363a4f", - "macBase": "#24273a", - "macMantle": "#1e2030", - "macCrust": "#181926" - }, - "theme": { - "primary": { - "dark": "macBlue", - "light": "macBlue" - }, - "secondary": { - "dark": "macMauve", - "light": "macMauve" - }, - "accent": { - "dark": "macPink", - "light": "macPink" - }, - "error": { - "dark": "macRed", - "light": "macRed" - }, - "warning": { - "dark": "macYellow", - "light": "macYellow" - }, - "success": { - "dark": "macGreen", - "light": "macGreen" - }, - "info": { - "dark": "macTeal", - "light": "macTeal" - }, - "text": { - "dark": "macText", - "light": "macText" - }, - "textMuted": { - "dark": "macSubtext1", - "light": "macSubtext1" - }, - "background": { - "dark": "macBase", - "light": "macBase" - }, - "backgroundPanel": { - "dark": "macMantle", - "light": "macMantle" - }, - "backgroundElement": { - "dark": "macCrust", - "light": "macCrust" - }, - "border": { - "dark": "macSurface0", - "light": "macSurface0" - }, - "borderActive": { - "dark": "macSurface1", - "light": "macSurface1" - }, - "borderSubtle": { - "dark": "macSurface2", - "light": "macSurface2" - }, - "diffAdded": { - "dark": "macGreen", - "light": "macGreen" - }, - "diffRemoved": { - "dark": "macRed", - "light": "macRed" - }, - "diffContext": { - "dark": "macOverlay2", - "light": "macOverlay2" - }, - "diffHunkHeader": { - "dark": "macPeach", - "light": "macPeach" - }, - "diffHighlightAdded": { - "dark": "macGreen", - "light": "macGreen" - }, - "diffHighlightRemoved": { - "dark": "macRed", - "light": "macRed" - }, - "diffAddedBg": { - "dark": "#29342b", - "light": "#29342b" - }, - "diffRemovedBg": { - "dark": "#3a2a31", - "light": "#3a2a31" - }, - "diffContextBg": { - "dark": "macMantle", - "light": "macMantle" - }, - "diffLineNumber": { - "dark": "macSurface1", - "light": "macSurface1" - }, - "diffAddedLineNumberBg": { - "dark": "#223025", - "light": "#223025" - }, - "diffRemovedLineNumberBg": { - "dark": "#2f242b", - "light": "#2f242b" - }, - "markdownText": { - "dark": "macText", - "light": "macText" - }, - "markdownHeading": { - "dark": "macMauve", - "light": "macMauve" - }, - "markdownLink": { - "dark": "macBlue", - "light": "macBlue" - }, - "markdownLinkText": { - "dark": "macSky", - "light": "macSky" - }, - "markdownCode": { - "dark": "macGreen", - "light": "macGreen" - }, - "markdownBlockQuote": { - "dark": "macYellow", - "light": "macYellow" - }, - "markdownEmph": { - "dark": "macYellow", - "light": "macYellow" - }, - "markdownStrong": { - "dark": "macPeach", - "light": "macPeach" - }, - "markdownHorizontalRule": { - "dark": "macSubtext0", - "light": "macSubtext0" - }, - "markdownListItem": { - "dark": "macBlue", - "light": "macBlue" - }, - "markdownListEnumeration": { - "dark": "macSky", - "light": "macSky" - }, - "markdownImage": { - "dark": "macBlue", - "light": "macBlue" - }, - "markdownImageText": { - "dark": "macSky", - "light": "macSky" - }, - "markdownCodeBlock": { - "dark": "macText", - "light": "macText" - }, - "syntaxComment": { - "dark": "macOverlay2", - "light": "macOverlay2" - }, - "syntaxKeyword": { - "dark": "macMauve", - "light": "macMauve" - }, - "syntaxFunction": { - "dark": "macBlue", - "light": "macBlue" - }, - "syntaxVariable": { - "dark": "macRed", - "light": "macRed" - }, - "syntaxString": { - "dark": "macGreen", - "light": "macGreen" - }, - "syntaxNumber": { - "dark": "macPeach", - "light": "macPeach" - }, - "syntaxType": { - "dark": "macYellow", - "light": "macYellow" - }, - "syntaxOperator": { - "dark": "macSky", - "light": "macSky" - }, - "syntaxPunctuation": { - "dark": "macText", - "light": "macText" - } - } - }, - "catppuccin": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "lightRosewater": "#dc8a78", - "lightFlamingo": "#dd7878", - "lightPink": "#ea76cb", - "lightMauve": "#8839ef", - "lightRed": "#d20f39", - "lightMaroon": "#e64553", - "lightPeach": "#fe640b", - "lightYellow": "#df8e1d", - "lightGreen": "#40a02b", - "lightTeal": "#179299", - "lightSky": "#04a5e5", - "lightSapphire": "#209fb5", - "lightBlue": "#1e66f5", - "lightLavender": "#7287fd", - "lightText": "#4c4f69", - "lightSubtext1": "#5c5f77", - "lightSubtext0": "#6c6f85", - "lightOverlay2": "#7c7f93", - "lightOverlay1": "#8c8fa1", - "lightOverlay0": "#9ca0b0", - "lightSurface2": "#acb0be", - "lightSurface1": "#bcc0cc", - "lightSurface0": "#ccd0da", - "lightBase": "#eff1f5", - "lightMantle": "#e6e9ef", - "lightCrust": "#dce0e8", - "darkRosewater": "#f5e0dc", - "darkFlamingo": "#f2cdcd", - "darkPink": "#f5c2e7", - "darkMauve": "#cba6f7", - "darkRed": "#f38ba8", - "darkMaroon": "#eba0ac", - "darkPeach": "#fab387", - "darkYellow": "#f9e2af", - "darkGreen": "#a6e3a1", - "darkTeal": "#94e2d5", - "darkSky": "#89dceb", - "darkSapphire": "#74c7ec", - "darkBlue": "#89b4fa", - "darkLavender": "#b4befe", - "darkText": "#cdd6f4", - "darkSubtext1": "#bac2de", - "darkSubtext0": "#a6adc8", - "darkOverlay2": "#9399b2", - "darkOverlay1": "#7f849c", - "darkOverlay0": "#6c7086", - "darkSurface2": "#585b70", - "darkSurface1": "#45475a", - "darkSurface0": "#313244", - "darkBase": "#1e1e2e", - "darkMantle": "#181825", - "darkCrust": "#11111b" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkMauve", - "light": "lightMauve" - }, - "accent": { - "dark": "darkPink", - "light": "lightPink" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkTeal", - "light": "lightTeal" - }, - "text": { - "dark": "darkText", - "light": "lightText" - }, - "textMuted": { - "dark": "darkSubtext1", - "light": "lightSubtext1" - }, - "background": { - "dark": "darkBase", - "light": "lightBase" - }, - "backgroundPanel": { - "dark": "darkMantle", - "light": "lightMantle" - }, - "backgroundElement": { - "dark": "darkCrust", - "light": "lightCrust" - }, - "border": { - "dark": "darkSurface0", - "light": "lightSurface0" - }, - "borderActive": { - "dark": "darkSurface1", - "light": "lightSurface1" - }, - "borderSubtle": { - "dark": "darkSurface2", - "light": "lightSurface2" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkOverlay2", - "light": "lightOverlay2" - }, - "diffHunkHeader": { - "dark": "darkPeach", - "light": "lightPeach" - }, - "diffHighlightAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#24312b", - "light": "#d6f0d9" - }, - "diffRemovedBg": { - "dark": "#3c2a32", - "light": "#f6dfe2" - }, - "diffContextBg": { - "dark": "darkMantle", - "light": "lightMantle" - }, - "diffLineNumber": { - "dark": "darkSurface1", - "light": "lightSurface1" - }, - "diffAddedLineNumberBg": { - "dark": "#1e2a25", - "light": "#c9e3cb" - }, - "diffRemovedLineNumberBg": { - "dark": "#32232a", - "light": "#e9d3d6" - }, - "markdownText": { - "dark": "darkText", - "light": "lightText" - }, - "markdownHeading": { - "dark": "darkMauve", - "light": "lightMauve" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkSky", - "light": "lightSky" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkPeach", - "light": "lightPeach" - }, - "markdownHorizontalRule": { - "dark": "darkSubtext0", - "light": "lightSubtext0" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkSky", - "light": "lightSky" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkSky", - "light": "lightSky" - }, - "markdownCodeBlock": { - "dark": "darkText", - "light": "lightText" - }, - "syntaxComment": { - "dark": "darkOverlay2", - "light": "lightOverlay2" - }, - "syntaxKeyword": { - "dark": "darkMauve", - "light": "lightMauve" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkPeach", - "light": "lightPeach" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkSky", - "light": "lightSky" - }, - "syntaxPunctuation": { - "dark": "darkText", - "light": "lightText" - } - } - }, - "cobalt2": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#193549", - "backgroundAlt": "#122738", - "backgroundPanel": "#1f4662", - "foreground": "#ffffff", - "foregroundMuted": "#adb7c9", - "yellow": "#ffc600", - "yellowBright": "#ffe14c", - "orange": "#ff9d00", - "orangeBright": "#ffb454", - "mint": "#2affdf", - "mintBright": "#7efff5", - "blue": "#0088ff", - "blueBright": "#5cb7ff", - "pink": "#ff628c", - "pinkBright": "#ff86a5", - "green": "#9eff80", - "greenBright": "#b9ff9f", - "purple": "#9a5feb", - "purpleBright": "#b88cfd", - "red": "#ff0088", - "redBright": "#ff5fb3" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "#0066cc" - }, - "secondary": { - "dark": "purple", - "light": "#7c4dff" - }, - "accent": { - "dark": "mint", - "light": "#00acc1" - }, - "error": { - "dark": "red", - "light": "#e91e63" - }, - "warning": { - "dark": "yellow", - "light": "#ff9800" - }, - "success": { - "dark": "green", - "light": "#4caf50" - }, - "info": { - "dark": "orange", - "light": "#ff5722" - }, - "text": { - "dark": "foreground", - "light": "#193549" - }, - "textMuted": { - "dark": "foregroundMuted", - "light": "#5c6b7d" - }, - "background": { - "dark": "#193549", - "light": "#ffffff" - }, - "backgroundPanel": { - "dark": "#122738", - "light": "#f5f7fa" - }, - "backgroundElement": { - "dark": "#1f4662", - "light": "#e8ecf1" - }, - "border": { - "dark": "#1f4662", - "light": "#d3dae3" - }, - "borderActive": { - "dark": "blue", - "light": "#0066cc" - }, - "borderSubtle": { - "dark": "#0e1e2e", - "light": "#e8ecf1" - }, - "diffAdded": { - "dark": "green", - "light": "#4caf50" - }, - "diffRemoved": { - "dark": "red", - "light": "#e91e63" - }, - "diffContext": { - "dark": "foregroundMuted", - "light": "#5c6b7d" - }, - "diffHunkHeader": { - "dark": "mint", - "light": "#00acc1" - }, - "diffHighlightAdded": { - "dark": "greenBright", - "light": "#4caf50" - }, - "diffHighlightRemoved": { - "dark": "redBright", - "light": "#e91e63" - }, - "diffAddedBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "#122738", - "light": "#f5f7fa" - }, - "diffLineNumber": { - "dark": "#2d5a7b", - "light": "#b0bec5" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "markdownText": { - "dark": "foreground", - "light": "#193549" - }, - "markdownHeading": { - "dark": "yellow", - "light": "#ff9800" - }, - "markdownLink": { - "dark": "blue", - "light": "#0066cc" - }, - "markdownLinkText": { - "dark": "mint", - "light": "#00acc1" - }, - "markdownCode": { - "dark": "green", - "light": "#4caf50" - }, - "markdownBlockQuote": { - "dark": "foregroundMuted", - "light": "#5c6b7d" - }, - "markdownEmph": { - "dark": "orange", - "light": "#ff5722" - }, - "markdownStrong": { - "dark": "pink", - "light": "#e91e63" - }, - "markdownHorizontalRule": { - "dark": "#2d5a7b", - "light": "#d3dae3" - }, - "markdownListItem": { - "dark": "blue", - "light": "#0066cc" - }, - "markdownListEnumeration": { - "dark": "mint", - "light": "#00acc1" - }, - "markdownImage": { - "dark": "blue", - "light": "#0066cc" - }, - "markdownImageText": { - "dark": "mint", - "light": "#00acc1" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#193549" - }, - "syntaxComment": { - "dark": "#0088ff", - "light": "#5c6b7d" - }, - "syntaxKeyword": { - "dark": "orange", - "light": "#ff5722" - }, - "syntaxFunction": { - "dark": "yellow", - "light": "#ff9800" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#193549" - }, - "syntaxString": { - "dark": "green", - "light": "#4caf50" - }, - "syntaxNumber": { - "dark": "pink", - "light": "#e91e63" - }, - "syntaxType": { - "dark": "mint", - "light": "#00acc1" - }, - "syntaxOperator": { - "dark": "orange", - "light": "#ff5722" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#193549" - } - } - }, - "cursor": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#181818", - "darkPanel": "#141414", - "darkElement": "#262626", - "darkFg": "#e4e4e4", - "darkMuted": "#e4e4e45e", - "darkBorder": "#e4e4e413", - "darkBorderActive": "#e4e4e426", - "darkCyan": "#88c0d0", - "darkBlue": "#81a1c1", - "darkGreen": "#3fa266", - "darkGreenBright": "#70b489", - "darkRed": "#e34671", - "darkRedBright": "#fc6b83", - "darkYellow": "#f1b467", - "darkOrange": "#d2943e", - "darkPink": "#E394DC", - "darkPurple": "#AAA0FA", - "darkTeal": "#82D2CE", - "darkSyntaxYellow": "#F8C762", - "darkSyntaxOrange": "#EFB080", - "darkSyntaxGreen": "#A8CC7C", - "darkSyntaxBlue": "#87C3FF", - "lightBg": "#fcfcfc", - "lightPanel": "#f3f3f3", - "lightElement": "#ededed", - "lightFg": "#141414", - "lightMuted": "#141414ad", - "lightBorder": "#14141413", - "lightBorderActive": "#14141426", - "lightTeal": "#6f9ba6", - "lightBlue": "#3c7cab", - "lightBlueDark": "#206595", - "lightGreen": "#1f8a65", - "lightGreenBright": "#55a583", - "lightRed": "#cf2d56", - "lightRedBright": "#e75e78", - "lightOrange": "#db704b", - "lightYellow": "#c08532", - "lightPurple": "#9e94d5", - "lightPurpleDark": "#6049b3", - "lightPink": "#b8448b", - "lightMagenta": "#b3003f" - }, - "theme": { - "primary": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "secondary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "accent": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "backgroundElement": { - "dark": "darkElement", - "light": "lightElement" - }, - "border": { - "dark": "darkBorder", - "light": "lightBorder" - }, - "borderActive": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "borderSubtle": { - "dark": "#0f0f0f", - "light": "#e0e0e0" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHunkHeader": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreenBright" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRedBright" - }, - "diffAddedBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "diffContextBg": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "diffLineNumber": { - "dark": "#e4e4e442", - "light": "#1414147a" - }, - "diffAddedLineNumberBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedLineNumberBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightBlueDark" - }, - "markdownLink": { - "dark": "darkTeal", - "light": "lightBlueDark" - }, - "markdownLinkText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownEmph": { - "dark": "darkTeal", - "light": "lightFg" - }, - "markdownStrong": { - "dark": "darkSyntaxYellow", - "light": "lightFg" - }, - "markdownHorizontalRule": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownListItem": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightMuted" - }, - "markdownImage": { - "dark": "darkCyan", - "light": "lightBlueDark" - }, - "markdownImageText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "syntaxKeyword": { - "dark": "darkTeal", - "light": "lightMagenta" - }, - "syntaxFunction": { - "dark": "darkSyntaxOrange", - "light": "lightOrange" - }, - "syntaxVariable": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxString": { - "dark": "darkPink", - "light": "lightPurple" - }, - "syntaxNumber": { - "dark": "darkSyntaxYellow", - "light": "lightPink" - }, - "syntaxType": { - "dark": "darkSyntaxOrange", - "light": "lightBlueDark" - }, - "syntaxOperator": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "dracula": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#282a36", - "currentLine": "#44475a", - "selection": "#44475a", - "foreground": "#f8f8f2", - "comment": "#6272a4", - "cyan": "#8be9fd", - "green": "#50fa7b", - "orange": "#ffb86c", - "pink": "#ff79c6", - "purple": "#bd93f9", - "red": "#ff5555", - "yellow": "#f1fa8c" - }, - "theme": { - "primary": { - "dark": "purple", - "light": "purple" - }, - "secondary": { - "dark": "pink", - "light": "pink" - }, - "accent": { - "dark": "cyan", - "light": "cyan" - }, - "error": { - "dark": "red", - "light": "red" - }, - "warning": { - "dark": "yellow", - "light": "yellow" - }, - "success": { - "dark": "green", - "light": "green" - }, - "info": { - "dark": "orange", - "light": "orange" - }, - "text": { - "dark": "foreground", - "light": "#282a36" - }, - "textMuted": { - "dark": "comment", - "light": "#6272a4" - }, - "background": { - "dark": "#282a36", - "light": "#f8f8f2" - }, - "backgroundPanel": { - "dark": "#21222c", - "light": "#e8e8e2" - }, - "backgroundElement": { - "dark": "currentLine", - "light": "#d8d8d2" - }, - "border": { - "dark": "currentLine", - "light": "#c8c8c2" - }, - "borderActive": { - "dark": "purple", - "light": "purple" - }, - "borderSubtle": { - "dark": "#191a21", - "light": "#e0e0e0" - }, - "diffAdded": { - "dark": "green", - "light": "green" - }, - "diffRemoved": { - "dark": "red", - "light": "red" - }, - "diffContext": { - "dark": "comment", - "light": "#6272a4" - }, - "diffHunkHeader": { - "dark": "comment", - "light": "#6272a4" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "green" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "red" - }, - "diffAddedBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "diffContextBg": { - "dark": "#21222c", - "light": "#e8e8e2" - }, - "diffLineNumber": { - "dark": "currentLine", - "light": "#c8c8c2" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "markdownText": { - "dark": "foreground", - "light": "#282a36" - }, - "markdownHeading": { - "dark": "purple", - "light": "purple" - }, - "markdownLink": { - "dark": "cyan", - "light": "cyan" - }, - "markdownLinkText": { - "dark": "pink", - "light": "pink" - }, - "markdownCode": { - "dark": "green", - "light": "green" - }, - "markdownBlockQuote": { - "dark": "comment", - "light": "#6272a4" - }, - "markdownEmph": { - "dark": "yellow", - "light": "yellow" - }, - "markdownStrong": { - "dark": "orange", - "light": "orange" - }, - "markdownHorizontalRule": { - "dark": "comment", - "light": "#6272a4" - }, - "markdownListItem": { - "dark": "purple", - "light": "purple" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImage": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImageText": { - "dark": "pink", - "light": "pink" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#282a36" - }, - "syntaxComment": { - "dark": "comment", - "light": "#6272a4" - }, - "syntaxKeyword": { - "dark": "pink", - "light": "pink" - }, - "syntaxFunction": { - "dark": "green", - "light": "green" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#282a36" - }, - "syntaxString": { - "dark": "yellow", - "light": "yellow" - }, - "syntaxNumber": { - "dark": "purple", - "light": "purple" - }, - "syntaxType": { - "dark": "cyan", - "light": "cyan" - }, - "syntaxOperator": { - "dark": "pink", - "light": "pink" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#282a36" - } - } - }, - "everforest": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#2d353b", - "darkStep2": "#333c43", - "darkStep3": "#343f44", - "darkStep4": "#3d484d", - "darkStep5": "#475258", - "darkStep6": "#7a8478", - "darkStep7": "#859289", - "darkStep8": "#9da9a0", - "darkStep9": "#a7c080", - "darkStep10": "#83c092", - "darkStep11": "#7a8478", - "darkStep12": "#d3c6aa", - "darkRed": "#e67e80", - "darkOrange": "#e69875", - "darkGreen": "#a7c080", - "darkCyan": "#83c092", - "darkYellow": "#dbbc7f", - "lightStep1": "#fdf6e3", - "lightStep2": "#efebd4", - "lightStep3": "#f4f0d9", - "lightStep4": "#efebd4", - "lightStep5": "#e6e2cc", - "lightStep6": "#a6b0a0", - "lightStep7": "#939f91", - "lightStep8": "#829181", - "lightStep9": "#8da101", - "lightStep10": "#35a77c", - "lightStep11": "#a6b0a0", - "lightStep12": "#5c6a72", - "lightRed": "#f85552", - "lightOrange": "#f57d26", - "lightGreen": "#8da101", - "lightCyan": "#35a77c", - "lightYellow": "#dfa000" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "#7fbbb3", - "light": "#3a94c5" - }, - "accent": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "flexoki": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "black": "#100F0F", - "base950": "#1C1B1A", - "base900": "#282726", - "base850": "#343331", - "base800": "#403E3C", - "base700": "#575653", - "base600": "#6F6E69", - "base500": "#878580", - "base300": "#B7B5AC", - "base200": "#CECDC3", - "base150": "#DAD8CE", - "base100": "#E6E4D9", - "base50": "#F2F0E5", - "paper": "#FFFCF0", - "red400": "#D14D41", - "red600": "#AF3029", - "orange400": "#DA702C", - "orange600": "#BC5215", - "yellow400": "#D0A215", - "yellow600": "#AD8301", - "green400": "#879A39", - "green600": "#66800B", - "cyan400": "#3AA99F", - "cyan600": "#24837B", - "blue400": "#4385BE", - "blue600": "#205EA6", - "purple400": "#8B7EC8", - "purple600": "#5E409D", - "magenta400": "#CE5D97", - "magenta600": "#A02F6F" - }, - "theme": { - "primary": { - "dark": "orange400", - "light": "blue600" - }, - "secondary": { - "dark": "blue400", - "light": "purple600" - }, - "accent": { - "dark": "purple400", - "light": "orange600" - }, - "error": { - "dark": "red400", - "light": "red600" - }, - "warning": { - "dark": "orange400", - "light": "orange600" - }, - "success": { - "dark": "green400", - "light": "green600" - }, - "info": { - "dark": "cyan400", - "light": "cyan600" - }, - "text": { - "dark": "base200", - "light": "black" - }, - "textMuted": { - "dark": "base600", - "light": "base600" - }, - "background": { - "dark": "black", - "light": "paper" - }, - "backgroundPanel": { - "dark": "base950", - "light": "base50" - }, - "backgroundElement": { - "dark": "base900", - "light": "base100" - }, - "border": { - "dark": "base700", - "light": "base300" - }, - "borderActive": { - "dark": "base600", - "light": "base500" - }, - "borderSubtle": { - "dark": "base800", - "light": "base200" - }, - "diffAdded": { - "dark": "green400", - "light": "green600" - }, - "diffRemoved": { - "dark": "red400", - "light": "red600" - }, - "diffContext": { - "dark": "base600", - "light": "base600" - }, - "diffHunkHeader": { - "dark": "blue400", - "light": "blue600" - }, - "diffHighlightAdded": { - "dark": "green400", - "light": "green600" - }, - "diffHighlightRemoved": { - "dark": "red400", - "light": "red600" - }, - "diffAddedBg": { - "dark": "#1A2D1A", - "light": "#D5E5D5" - }, - "diffRemovedBg": { - "dark": "#2D1A1A", - "light": "#F7D8DB" - }, - "diffContextBg": { - "dark": "base950", - "light": "base50" - }, - "diffLineNumber": { - "dark": "base600", - "light": "base600" - }, - "diffAddedLineNumberBg": { - "dark": "#152515", - "light": "#C5D5C5" - }, - "diffRemovedLineNumberBg": { - "dark": "#251515", - "light": "#E7C8CB" - }, - "markdownText": { - "dark": "base200", - "light": "black" - }, - "markdownHeading": { - "dark": "purple400", - "light": "purple600" - }, - "markdownLink": { - "dark": "blue400", - "light": "blue600" - }, - "markdownLinkText": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownCode": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownBlockQuote": { - "dark": "yellow400", - "light": "yellow600" - }, - "markdownEmph": { - "dark": "yellow400", - "light": "yellow600" - }, - "markdownStrong": { - "dark": "orange400", - "light": "orange600" - }, - "markdownHorizontalRule": { - "dark": "base600", - "light": "base600" - }, - "markdownListItem": { - "dark": "orange400", - "light": "orange600" - }, - "markdownListEnumeration": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownImage": { - "dark": "magenta400", - "light": "magenta600" - }, - "markdownImageText": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownCodeBlock": { - "dark": "base200", - "light": "black" - }, - "syntaxComment": { - "dark": "base600", - "light": "base600" - }, - "syntaxKeyword": { - "dark": "green400", - "light": "green600" - }, - "syntaxFunction": { - "dark": "orange400", - "light": "orange600" - }, - "syntaxVariable": { - "dark": "blue400", - "light": "blue600" - }, - "syntaxString": { - "dark": "cyan400", - "light": "cyan600" - }, - "syntaxNumber": { - "dark": "purple400", - "light": "purple600" - }, - "syntaxType": { - "dark": "yellow400", - "light": "yellow600" - }, - "syntaxOperator": { - "dark": "base300", - "light": "base600" - }, - "syntaxPunctuation": { - "dark": "base300", - "light": "base600" - } - } - }, - "github": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0d1117", - "darkBgAlt": "#010409", - "darkBgPanel": "#161b22", - "darkFg": "#c9d1d9", - "darkFgMuted": "#8b949e", - "darkBlue": "#58a6ff", - "darkGreen": "#3fb950", - "darkRed": "#f85149", - "darkOrange": "#d29922", - "darkPurple": "#bc8cff", - "darkPink": "#ff7b72", - "darkYellow": "#e3b341", - "darkCyan": "#39c5cf", - "lightBg": "#ffffff", - "lightBgAlt": "#f6f8fa", - "lightBgPanel": "#f0f3f6", - "lightFg": "#24292f", - "lightFgMuted": "#57606a", - "lightBlue": "#0969da", - "lightGreen": "#1a7f37", - "lightRed": "#cf222e", - "lightOrange": "#bc4c00", - "lightPurple": "#8250df", - "lightPink": "#bf3989", - "lightYellow": "#9a6700", - "lightCyan": "#1b7c83" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#21262d", - "light": "#d8dee4" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffHighlightAdded": { - "dark": "#3fb950", - "light": "#1a7f37" - }, - "diffHighlightRemoved": { - "dark": "#f85149", - "light": "#cf222e" - }, - "diffAddedBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#484f58", - "light": "#afb8c1" - }, - "diffAddedLineNumberBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedLineNumberBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightPink" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxFunction": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxVariable": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxString": { - "dark": "darkCyan", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "darkBlue", - "light": "lightCyan" - }, - "syntaxType": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxOperator": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "gruvbox": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg0": "#282828", - "darkBg1": "#3c3836", - "darkBg2": "#504945", - "darkBg3": "#665c54", - "darkFg0": "#fbf1c7", - "darkFg1": "#ebdbb2", - "darkGray": "#928374", - "darkRed": "#cc241d", - "darkGreen": "#98971a", - "darkYellow": "#d79921", - "darkBlue": "#458588", - "darkPurple": "#b16286", - "darkAqua": "#689d6a", - "darkOrange": "#d65d0e", - "darkRedBright": "#fb4934", - "darkGreenBright": "#b8bb26", - "darkYellowBright": "#fabd2f", - "darkBlueBright": "#83a598", - "darkPurpleBright": "#d3869b", - "darkAquaBright": "#8ec07c", - "darkOrangeBright": "#fe8019", - "lightBg0": "#fbf1c7", - "lightBg1": "#ebdbb2", - "lightBg2": "#d5c4a1", - "lightBg3": "#bdae93", - "lightFg0": "#282828", - "lightFg1": "#3c3836", - "lightGray": "#7c6f64", - "lightRed": "#9d0006", - "lightGreen": "#79740e", - "lightYellow": "#b57614", - "lightBlue": "#076678", - "lightPurple": "#8f3f71", - "lightAqua": "#427b58", - "lightOrange": "#af3a03" - }, - "theme": { - "primary": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurpleBright", - "light": "lightPurple" - }, - "accent": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "error": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrangeBright", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "info": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "text": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "textMuted": { - "dark": "darkGray", - "light": "lightGray" - }, - "background": { - "dark": "darkBg0", - "light": "lightBg0" - }, - "backgroundPanel": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "backgroundElement": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "border": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "borderActive": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "borderSubtle": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "darkAqua", - "light": "lightAqua" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#32302f", - "light": "#dcd8a4" - }, - "diffRemovedBg": { - "dark": "#322929", - "light": "#e2c7c3" - }, - "diffContextBg": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "diffLineNumber": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "diffAddedLineNumberBg": { - "dark": "#2a2827", - "light": "#cec99e" - }, - "diffRemovedLineNumberBg": { - "dark": "#2a2222", - "light": "#d3bdb9" - }, - "markdownText": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "markdownHeading": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "markdownLinkText": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "markdownCode": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "markdownBlockQuote": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "darkPurpleBright", - "light": "lightPurple" - }, - "markdownStrong": { - "dark": "darkOrangeBright", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "markdownImage": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "markdownImageText": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "markdownCodeBlock": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "syntaxComment": { - "dark": "darkGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "syntaxFunction": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "syntaxVariable": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "syntaxString": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "syntaxNumber": { - "dark": "darkPurpleBright", - "light": "lightPurple" - }, - "syntaxType": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "syntaxOperator": { - "dark": "darkOrangeBright", - "light": "lightOrange" - }, - "syntaxPunctuation": { - "dark": "darkFg1", - "light": "lightFg1" - } - } - }, - "kanagawa": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "sumiInk0": "#1F1F28", - "sumiInk1": "#2A2A37", - "sumiInk2": "#363646", - "sumiInk3": "#54546D", - "fujiWhite": "#DCD7BA", - "oldWhite": "#C8C093", - "fujiGray": "#727169", - "oniViolet": "#957FB8", - "crystalBlue": "#7E9CD8", - "carpYellow": "#C38D9D", - "sakuraPink": "#D27E99", - "waveAqua": "#76946A", - "roninYellow": "#D7A657", - "dragonRed": "#E82424", - "lotusGreen": "#98BB6C", - "waveBlue": "#2D4F67", - "lightBg": "#F2E9DE", - "lightPaper": "#EAE4D7", - "lightText": "#54433A", - "lightGray": "#9E9389" - }, - "theme": { - "primary": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "secondary": { - "dark": "oniViolet", - "light": "oniViolet" - }, - "accent": { - "dark": "sakuraPink", - "light": "sakuraPink" - }, - "error": { - "dark": "dragonRed", - "light": "dragonRed" - }, - "warning": { - "dark": "roninYellow", - "light": "roninYellow" - }, - "success": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "info": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "text": { - "dark": "fujiWhite", - "light": "lightText" - }, - "textMuted": { - "dark": "fujiGray", - "light": "lightGray" - }, - "background": { - "dark": "sumiInk0", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "sumiInk1", - "light": "lightPaper" - }, - "backgroundElement": { - "dark": "sumiInk2", - "light": "#E3DCD2" - }, - "border": { - "dark": "sumiInk3", - "light": "#D4CBBF" - }, - "borderActive": { - "dark": "carpYellow", - "light": "carpYellow" - }, - "borderSubtle": { - "dark": "sumiInk2", - "light": "#DCD4C9" - }, - "diffAdded": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "diffRemoved": { - "dark": "dragonRed", - "light": "dragonRed" - }, - "diffContext": { - "dark": "fujiGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "waveBlue", - "light": "waveBlue" - }, - "diffHighlightAdded": { - "dark": "#A9D977", - "light": "#89AF5B" - }, - "diffHighlightRemoved": { - "dark": "#F24A4A", - "light": "#D61F1F" - }, - "diffAddedBg": { - "dark": "#252E25", - "light": "#EAF3E4" - }, - "diffRemovedBg": { - "dark": "#362020", - "light": "#FBE6E6" - }, - "diffContextBg": { - "dark": "sumiInk1", - "light": "lightPaper" - }, - "diffLineNumber": { - "dark": "sumiInk3", - "light": "#C7BEB4" - }, - "diffAddedLineNumberBg": { - "dark": "#202820", - "light": "#DDE8D6" - }, - "diffRemovedLineNumberBg": { - "dark": "#2D1C1C", - "light": "#F2DADA" - }, - "markdownText": { - "dark": "fujiWhite", - "light": "lightText" - }, - "markdownHeading": { - "dark": "oniViolet", - "light": "oniViolet" - }, - "markdownLink": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "markdownLinkText": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "markdownCode": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "markdownBlockQuote": { - "dark": "fujiGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "carpYellow", - "light": "carpYellow" - }, - "markdownStrong": { - "dark": "roninYellow", - "light": "roninYellow" - }, - "markdownHorizontalRule": { - "dark": "fujiGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "markdownListEnumeration": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "markdownImage": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "markdownImageText": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "markdownCodeBlock": { - "dark": "fujiWhite", - "light": "lightText" - }, - "syntaxComment": { - "dark": "fujiGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "oniViolet", - "light": "oniViolet" - }, - "syntaxFunction": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "syntaxVariable": { - "dark": "fujiWhite", - "light": "lightText" - }, - "syntaxString": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "syntaxNumber": { - "dark": "roninYellow", - "light": "roninYellow" - }, - "syntaxType": { - "dark": "carpYellow", - "light": "carpYellow" - }, - "syntaxOperator": { - "dark": "sakuraPink", - "light": "sakuraPink" - }, - "syntaxPunctuation": { - "dark": "fujiWhite", - "light": "lightText" - } - } - }, - "lucent-orng": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep6": "#3c3c3c", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#EE7948", - "darkAccent": "#FFF7F1", - "darkRed": "#e06c75", - "darkOrange": "#EC5B2B", - "darkBlue": "#6ba1e6", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "darkPanelBg": "#2a1a1599", - "lightStep6": "#d4d4d4", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#EE7948", - "lightAccent": "#c94d24", - "lightRed": "#d1383d", - "lightOrange": "#EC5B2B", - "lightBlue": "#0062d1", - "lightCyan": "#318795", - "lightYellow": "#b0851f", - "lightPanelBg": "#fff5f099" - }, - "theme": { - "primary": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "selectedListItemText": { - "dark": "#0a0a0a", - "light": "#ffffff" - }, - "background": { - "dark": "transparent", - "light": "transparent" - }, - "backgroundPanel": { - "dark": "transparent", - "light": "transparent" - }, - "backgroundElement": { - "dark": "transparent", - "light": "transparent" - }, - "backgroundMenu": { - "dark": "darkPanelBg", - "light": "lightPanelBg" - }, - "border": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "borderActive": { - "dark": "darkSecondary", - "light": "lightAccent" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffRemovedBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffContextBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffLineNumber": { - "dark": "#666666", - "light": "#999999" - }, - "diffAddedLineNumberBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffRemovedLineNumberBg": { - "dark": "transparent", - "light": "transparent" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownLink": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownBlockQuote": { - "dark": "darkAccent", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkSecondary", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxFunction": { - "dark": "darkSecondary", - "light": "lightAccent" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "darkAccent", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "material": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#263238", - "darkBgAlt": "#1e272c", - "darkBgPanel": "#37474f", - "darkFg": "#eeffff", - "darkFgMuted": "#546e7a", - "darkRed": "#f07178", - "darkPink": "#f78c6c", - "darkOrange": "#ffcb6b", - "darkYellow": "#ffcb6b", - "darkGreen": "#c3e88d", - "darkCyan": "#89ddff", - "darkBlue": "#82aaff", - "darkPurple": "#c792ea", - "darkViolet": "#bb80b3", - "lightBg": "#fafafa", - "lightBgAlt": "#f5f5f5", - "lightBgPanel": "#e7e7e8", - "lightFg": "#263238", - "lightFgMuted": "#90a4ae", - "lightRed": "#e53935", - "lightPink": "#ec407a", - "lightOrange": "#f4511e", - "lightYellow": "#ffb300", - "lightGreen": "#91b859", - "lightCyan": "#39adb5", - "lightBlue": "#6182b8", - "lightPurple": "#7c4dff", - "lightViolet": "#945eb8" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#37474f", - "light": "#e0e0e0" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#1e272c", - "light": "#eeeeee" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "diffHighlightAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#37474f", - "light": "#cfd8dc" - }, - "diffAddedLineNumberBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownLinkText": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "#37474f", - "light": "#e0e0e0" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImageText": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "matrix": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "matrixInk0": "#0a0e0a", - "matrixInk1": "#0e130d", - "matrixInk2": "#141c12", - "matrixInk3": "#1e2a1b", - "rainGreen": "#2eff6a", - "rainGreenDim": "#1cc24b", - "rainGreenHi": "#62ff94", - "rainCyan": "#00efff", - "rainTeal": "#24f6d9", - "rainPurple": "#c770ff", - "rainOrange": "#ffa83d", - "alertRed": "#ff4b4b", - "alertYellow": "#e6ff57", - "alertBlue": "#30b3ff", - "rainGray": "#8ca391", - "lightBg": "#eef3ea", - "lightPaper": "#e4ebe1", - "lightInk1": "#dae1d7", - "lightText": "#203022", - "lightGray": "#748476" - }, - "theme": { - "primary": { - "dark": "rainGreen", - "light": "rainGreenDim" - }, - "secondary": { - "dark": "rainCyan", - "light": "rainTeal" - }, - "accent": { - "dark": "rainPurple", - "light": "rainPurple" - }, - "error": { - "dark": "alertRed", - "light": "alertRed" - }, - "warning": { - "dark": "alertYellow", - "light": "alertYellow" - }, - "success": { - "dark": "rainGreenHi", - "light": "rainGreenDim" - }, - "info": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "text": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "textMuted": { - "dark": "rainGray", - "light": "lightGray" - }, - "background": { - "dark": "matrixInk0", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "matrixInk1", - "light": "lightPaper" - }, - "backgroundElement": { - "dark": "matrixInk2", - "light": "lightInk1" - }, - "border": { - "dark": "matrixInk3", - "light": "lightGray" - }, - "borderActive": { - "dark": "rainGreen", - "light": "rainGreenDim" - }, - "borderSubtle": { - "dark": "matrixInk2", - "light": "lightInk1" - }, - "diffAdded": { - "dark": "rainGreenDim", - "light": "rainGreenDim" - }, - "diffRemoved": { - "dark": "alertRed", - "light": "alertRed" - }, - "diffContext": { - "dark": "rainGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "diffHighlightAdded": { - "dark": "#77ffaf", - "light": "#5dac7e" - }, - "diffHighlightRemoved": { - "dark": "#ff7171", - "light": "#d53a3a" - }, - "diffAddedBg": { - "dark": "#132616", - "light": "#e0efde" - }, - "diffRemovedBg": { - "dark": "#261212", - "light": "#f9e5e5" - }, - "diffContextBg": { - "dark": "matrixInk1", - "light": "lightPaper" - }, - "diffLineNumber": { - "dark": "matrixInk3", - "light": "lightGray" - }, - "diffAddedLineNumberBg": { - "dark": "#0f1b11", - "light": "#d6e7d2" - }, - "diffRemovedLineNumberBg": { - "dark": "#1b1414", - "light": "#f2d2d2" - }, - "markdownText": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "markdownHeading": { - "dark": "rainCyan", - "light": "rainTeal" - }, - "markdownLink": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "markdownLinkText": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "markdownCode": { - "dark": "rainGreenDim", - "light": "rainGreenDim" - }, - "markdownBlockQuote": { - "dark": "rainGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "rainOrange", - "light": "rainOrange" - }, - "markdownStrong": { - "dark": "alertYellow", - "light": "alertYellow" - }, - "markdownHorizontalRule": { - "dark": "rainGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "markdownListEnumeration": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "markdownImage": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "markdownImageText": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "markdownCodeBlock": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "syntaxComment": { - "dark": "rainGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "rainPurple", - "light": "rainPurple" - }, - "syntaxFunction": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "syntaxVariable": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "syntaxString": { - "dark": "rainGreenDim", - "light": "rainGreenDim" - }, - "syntaxNumber": { - "dark": "rainOrange", - "light": "rainOrange" - }, - "syntaxType": { - "dark": "alertYellow", - "light": "alertYellow" - }, - "syntaxOperator": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "syntaxPunctuation": { - "dark": "rainGreenHi", - "light": "lightText" - } - } - }, - "mercury": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "purple-800": "#3442a6", - "purple-700": "#465bd1", - "purple-600": "#5266eb", - "purple-400": "#8da4f5", - "purple-300": "#a7b6f8", - "red-700": "#b0175f", - "red-600": "#d03275", - "red-400": "#fc92b4", - "green-700": "#036e43", - "green-600": "#188554", - "green-400": "#77c599", - "orange-700": "#a44200", - "orange-600": "#c45000", - "orange-400": "#fc9b6f", - "blue-600": "#007f95", - "blue-400": "#77becf", - "neutral-1000": "#10101a", - "neutral-950": "#171721", - "neutral-900": "#1e1e2a", - "neutral-800": "#272735", - "neutral-700": "#363644", - "neutral-600": "#535461", - "neutral-500": "#70707d", - "neutral-400": "#9d9da8", - "neutral-300": "#c3c3cc", - "neutral-200": "#dddde5", - "neutral-100": "#f4f5f9", - "neutral-050": "#fbfcfd", - "neutral-000": "#ffffff", - "neutral-150": "#ededf3", - "border-light": "#7073931a", - "border-light-subtle": "#7073930f", - "border-dark": "#b4b7c81f", - "border-dark-subtle": "#b4b7c814", - "diff-added-light": "#1885541a", - "diff-removed-light": "#d032751a", - "diff-added-dark": "#77c59933", - "diff-removed-dark": "#fc92b433" - }, - "theme": { - "primary": { - "light": "purple-600", - "dark": "purple-400" - }, - "secondary": { - "light": "purple-700", - "dark": "purple-300" - }, - "accent": { - "light": "purple-400", - "dark": "purple-400" - }, - "error": { - "light": "red-700", - "dark": "red-400" - }, - "warning": { - "light": "orange-700", - "dark": "orange-400" - }, - "success": { - "light": "green-700", - "dark": "green-400" - }, - "info": { - "light": "blue-600", - "dark": "blue-400" - }, - "text": { - "light": "neutral-700", - "dark": "neutral-200" - }, - "textMuted": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "background": { - "light": "neutral-000", - "dark": "neutral-950" - }, - "backgroundPanel": { - "light": "neutral-050", - "dark": "neutral-1000" - }, - "backgroundElement": { - "light": "neutral-100", - "dark": "neutral-800" - }, - "border": { - "light": "border-light", - "dark": "border-dark" - }, - "borderActive": { - "light": "purple-600", - "dark": "purple-400" - }, - "borderSubtle": { - "light": "border-light-subtle", - "dark": "border-dark-subtle" - }, - "diffAdded": { - "light": "green-700", - "dark": "green-400" - }, - "diffRemoved": { - "light": "red-700", - "dark": "red-400" - }, - "diffContext": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "diffHunkHeader": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "diffHighlightAdded": { - "light": "green-700", - "dark": "green-400" - }, - "diffHighlightRemoved": { - "light": "red-700", - "dark": "red-400" - }, - "diffAddedBg": { - "light": "diff-added-light", - "dark": "diff-added-dark" - }, - "diffRemovedBg": { - "light": "diff-removed-light", - "dark": "diff-removed-dark" - }, - "diffContextBg": { - "light": "neutral-050", - "dark": "neutral-900" - }, - "diffLineNumber": { - "light": "neutral-600", - "dark": "neutral-300" - }, - "diffAddedLineNumberBg": { - "light": "diff-added-light", - "dark": "diff-added-dark" - }, - "diffRemovedLineNumberBg": { - "light": "diff-removed-light", - "dark": "diff-removed-dark" - }, - "markdownText": { - "light": "neutral-700", - "dark": "neutral-200" - }, - "markdownHeading": { - "light": "neutral-900", - "dark": "neutral-000" - }, - "markdownLink": { - "light": "purple-700", - "dark": "purple-400" - }, - "markdownLinkText": { - "light": "purple-600", - "dark": "purple-300" - }, - "markdownCode": { - "light": "green-700", - "dark": "green-400" - }, - "markdownBlockQuote": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "markdownEmph": { - "light": "orange-700", - "dark": "orange-400" - }, - "markdownStrong": { - "light": "neutral-900", - "dark": "neutral-100" - }, - "markdownHorizontalRule": { - "light": "border-light", - "dark": "border-dark" - }, - "markdownListItem": { - "light": "neutral-900", - "dark": "neutral-000" - }, - "markdownListEnumeration": { - "light": "purple-600", - "dark": "purple-400" - }, - "markdownImage": { - "light": "purple-700", - "dark": "purple-400" - }, - "markdownImageText": { - "light": "purple-600", - "dark": "purple-300" - }, - "markdownCodeBlock": { - "light": "neutral-700", - "dark": "neutral-200" - }, - "syntaxComment": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "syntaxKeyword": { - "light": "purple-700", - "dark": "purple-400" - }, - "syntaxFunction": { - "light": "purple-600", - "dark": "purple-400" - }, - "syntaxVariable": { - "light": "blue-600", - "dark": "blue-400" - }, - "syntaxString": { - "light": "green-700", - "dark": "green-400" - }, - "syntaxNumber": { - "light": "orange-700", - "dark": "orange-400" - }, - "syntaxType": { - "light": "blue-600", - "dark": "blue-400" - }, - "syntaxOperator": { - "light": "purple-700", - "dark": "purple-400" - }, - "syntaxPunctuation": { - "light": "neutral-700", - "dark": "neutral-200" - } - } - }, - "monokai": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#272822", - "backgroundAlt": "#1e1f1c", - "backgroundPanel": "#3e3d32", - "foreground": "#f8f8f2", - "comment": "#75715e", - "red": "#f92672", - "orange": "#fd971f", - "lightOrange": "#e69f66", - "yellow": "#e6db74", - "green": "#a6e22e", - "cyan": "#66d9ef", - "blue": "#66d9ef", - "purple": "#ae81ff", - "pink": "#f92672" - }, - "theme": { - "primary": { - "dark": "cyan", - "light": "blue" - }, - "secondary": { - "dark": "purple", - "light": "purple" - }, - "accent": { - "dark": "green", - "light": "green" - }, - "error": { - "dark": "red", - "light": "red" - }, - "warning": { - "dark": "yellow", - "light": "orange" - }, - "success": { - "dark": "green", - "light": "green" - }, - "info": { - "dark": "orange", - "light": "orange" - }, - "text": { - "dark": "foreground", - "light": "#272822" - }, - "textMuted": { - "dark": "comment", - "light": "#75715e" - }, - "background": { - "dark": "#272822", - "light": "#fafafa" - }, - "backgroundPanel": { - "dark": "#1e1f1c", - "light": "#f0f0f0" - }, - "backgroundElement": { - "dark": "#3e3d32", - "light": "#e0e0e0" - }, - "border": { - "dark": "#3e3d32", - "light": "#d0d0d0" - }, - "borderActive": { - "dark": "cyan", - "light": "blue" - }, - "borderSubtle": { - "dark": "#1e1f1c", - "light": "#e8e8e8" - }, - "diffAdded": { - "dark": "green", - "light": "green" - }, - "diffRemoved": { - "dark": "red", - "light": "red" - }, - "diffContext": { - "dark": "comment", - "light": "#75715e" - }, - "diffHunkHeader": { - "dark": "comment", - "light": "#75715e" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "green" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "red" - }, - "diffAddedBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "diffContextBg": { - "dark": "#1e1f1c", - "light": "#f0f0f0" - }, - "diffLineNumber": { - "dark": "#3e3d32", - "light": "#d0d0d0" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "markdownText": { - "dark": "foreground", - "light": "#272822" - }, - "markdownHeading": { - "dark": "pink", - "light": "pink" - }, - "markdownLink": { - "dark": "cyan", - "light": "blue" - }, - "markdownLinkText": { - "dark": "purple", - "light": "purple" - }, - "markdownCode": { - "dark": "green", - "light": "green" - }, - "markdownBlockQuote": { - "dark": "comment", - "light": "#75715e" - }, - "markdownEmph": { - "dark": "yellow", - "light": "orange" - }, - "markdownStrong": { - "dark": "orange", - "light": "orange" - }, - "markdownHorizontalRule": { - "dark": "comment", - "light": "#75715e" - }, - "markdownListItem": { - "dark": "cyan", - "light": "blue" - }, - "markdownListEnumeration": { - "dark": "purple", - "light": "purple" - }, - "markdownImage": { - "dark": "cyan", - "light": "blue" - }, - "markdownImageText": { - "dark": "purple", - "light": "purple" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#272822" - }, - "syntaxComment": { - "dark": "comment", - "light": "#75715e" - }, - "syntaxKeyword": { - "dark": "pink", - "light": "pink" - }, - "syntaxFunction": { - "dark": "green", - "light": "green" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#272822" - }, - "syntaxString": { - "dark": "yellow", - "light": "orange" - }, - "syntaxNumber": { - "dark": "purple", - "light": "purple" - }, - "syntaxType": { - "dark": "cyan", - "light": "blue" - }, - "syntaxOperator": { - "dark": "pink", - "light": "pink" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#272822" - } - } - }, - "nightowl": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "nightOwlBg": "#011627", - "nightOwlFg": "#d6deeb", - "nightOwlBlue": "#82AAFF", - "nightOwlCyan": "#7fdbca", - "nightOwlGreen": "#c5e478", - "nightOwlYellow": "#ecc48d", - "nightOwlOrange": "#F78C6C", - "nightOwlRed": "#EF5350", - "nightOwlPink": "#ff5874", - "nightOwlPurple": "#c792ea", - "nightOwlMuted": "#5f7e97", - "nightOwlGray": "#637777", - "nightOwlLightGray": "#89a4bb", - "nightOwlPanel": "#0b253a" - }, - "theme": { - "primary": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "secondary": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "accent": { - "dark": "nightOwlPurple", - "light": "nightOwlPurple" - }, - "error": { - "dark": "nightOwlRed", - "light": "nightOwlRed" - }, - "warning": { - "dark": "nightOwlYellow", - "light": "nightOwlYellow" - }, - "success": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "info": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "text": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "textMuted": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "background": { - "dark": "nightOwlBg", - "light": "nightOwlBg" - }, - "backgroundPanel": { - "dark": "nightOwlPanel", - "light": "nightOwlPanel" - }, - "backgroundElement": { - "dark": "nightOwlPanel", - "light": "nightOwlPanel" - }, - "border": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "borderActive": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "borderSubtle": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffAdded": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "diffRemoved": { - "dark": "nightOwlRed", - "light": "nightOwlRed" - }, - "diffContext": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffHunkHeader": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffHighlightAdded": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "diffHighlightRemoved": { - "dark": "nightOwlRed", - "light": "nightOwlRed" - }, - "diffAddedBg": { - "dark": "#0a2e1a", - "light": "#0a2e1a" - }, - "diffRemovedBg": { - "dark": "#2d1b1b", - "light": "#2d1b1b" - }, - "diffContextBg": { - "dark": "nightOwlPanel", - "light": "nightOwlPanel" - }, - "diffLineNumber": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffAddedLineNumberBg": { - "dark": "#0a2e1a", - "light": "#0a2e1a" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1b1b", - "light": "#2d1b1b" - }, - "markdownText": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "markdownHeading": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownLink": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "markdownLinkText": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownCode": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "markdownBlockQuote": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "markdownEmph": { - "dark": "nightOwlPurple", - "light": "nightOwlPurple" - }, - "markdownStrong": { - "dark": "nightOwlYellow", - "light": "nightOwlYellow" - }, - "markdownHorizontalRule": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "markdownListItem": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownListEnumeration": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "markdownImage": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "markdownImageText": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownCodeBlock": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "syntaxComment": { - "dark": "nightOwlGray", - "light": "nightOwlGray" - }, - "syntaxKeyword": { - "dark": "nightOwlPurple", - "light": "nightOwlPurple" - }, - "syntaxFunction": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "syntaxVariable": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "syntaxString": { - "dark": "nightOwlYellow", - "light": "nightOwlYellow" - }, - "syntaxNumber": { - "dark": "nightOwlOrange", - "light": "nightOwlOrange" - }, - "syntaxType": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "syntaxOperator": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "syntaxPunctuation": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - } - } - }, - "nord": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "nord0": "#2E3440", - "nord1": "#3B4252", - "nord2": "#434C5E", - "nord3": "#4C566A", - "nord4": "#D8DEE9", - "nord5": "#E5E9F0", - "nord6": "#ECEFF4", - "nord7": "#8FBCBB", - "nord8": "#88C0D0", - "nord9": "#81A1C1", - "nord10": "#5E81AC", - "nord11": "#BF616A", - "nord12": "#D08770", - "nord13": "#EBCB8B", - "nord14": "#A3BE8C", - "nord15": "#B48EAD" - }, - "theme": { - "primary": { - "dark": "nord8", - "light": "nord10" - }, - "secondary": { - "dark": "nord9", - "light": "nord9" - }, - "accent": { - "dark": "nord7", - "light": "nord7" - }, - "error": { - "dark": "nord11", - "light": "nord11" - }, - "warning": { - "dark": "nord12", - "light": "nord12" - }, - "success": { - "dark": "nord14", - "light": "nord14" - }, - "info": { - "dark": "nord8", - "light": "nord10" - }, - "text": { - "dark": "nord6", - "light": "nord0" - }, - "textMuted": { - "dark": "#8B95A7", - "light": "nord1" - }, - "background": { - "dark": "nord0", - "light": "nord6" - }, - "backgroundPanel": { - "dark": "nord1", - "light": "nord5" - }, - "backgroundElement": { - "dark": "nord2", - "light": "nord4" - }, - "border": { - "dark": "nord2", - "light": "nord3" - }, - "borderActive": { - "dark": "nord3", - "light": "nord2" - }, - "borderSubtle": { - "dark": "nord2", - "light": "nord3" - }, - "diffAdded": { - "dark": "nord14", - "light": "nord14" - }, - "diffRemoved": { - "dark": "nord11", - "light": "nord11" - }, - "diffContext": { - "dark": "#8B95A7", - "light": "nord3" - }, - "diffHunkHeader": { - "dark": "#8B95A7", - "light": "nord3" - }, - "diffHighlightAdded": { - "dark": "nord14", - "light": "nord14" - }, - "diffHighlightRemoved": { - "dark": "nord11", - "light": "nord11" - }, - "diffAddedBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffRemovedBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffContextBg": { - "dark": "nord1", - "light": "nord5" - }, - "diffLineNumber": { - "dark": "nord2", - "light": "nord4" - }, - "diffAddedLineNumberBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "markdownText": { - "dark": "nord4", - "light": "nord0" - }, - "markdownHeading": { - "dark": "nord8", - "light": "nord10" - }, - "markdownLink": { - "dark": "nord9", - "light": "nord9" - }, - "markdownLinkText": { - "dark": "nord7", - "light": "nord7" - }, - "markdownCode": { - "dark": "nord14", - "light": "nord14" - }, - "markdownBlockQuote": { - "dark": "#8B95A7", - "light": "nord3" - }, - "markdownEmph": { - "dark": "nord12", - "light": "nord12" - }, - "markdownStrong": { - "dark": "nord13", - "light": "nord13" - }, - "markdownHorizontalRule": { - "dark": "#8B95A7", - "light": "nord3" - }, - "markdownListItem": { - "dark": "nord8", - "light": "nord10" - }, - "markdownListEnumeration": { - "dark": "nord7", - "light": "nord7" - }, - "markdownImage": { - "dark": "nord9", - "light": "nord9" - }, - "markdownImageText": { - "dark": "nord7", - "light": "nord7" - }, - "markdownCodeBlock": { - "dark": "nord4", - "light": "nord0" - }, - "syntaxComment": { - "dark": "#8B95A7", - "light": "nord3" - }, - "syntaxKeyword": { - "dark": "nord9", - "light": "nord9" - }, - "syntaxFunction": { - "dark": "nord8", - "light": "nord8" - }, - "syntaxVariable": { - "dark": "nord7", - "light": "nord7" - }, - "syntaxString": { - "dark": "nord14", - "light": "nord14" - }, - "syntaxNumber": { - "dark": "nord15", - "light": "nord15" - }, - "syntaxType": { - "dark": "nord7", - "light": "nord7" - }, - "syntaxOperator": { - "dark": "nord9", - "light": "nord9" - }, - "syntaxPunctuation": { - "dark": "nord4", - "light": "nord0" - } - } - }, - "one-dark": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#282c34", - "darkBgAlt": "#21252b", - "darkBgPanel": "#353b45", - "darkFg": "#abb2bf", - "darkFgMuted": "#5c6370", - "darkPurple": "#c678dd", - "darkBlue": "#61afef", - "darkRed": "#e06c75", - "darkGreen": "#98c379", - "darkYellow": "#e5c07b", - "darkOrange": "#d19a66", - "darkCyan": "#56b6c2", - "lightBg": "#fafafa", - "lightBgAlt": "#f0f0f1", - "lightBgPanel": "#eaeaeb", - "lightFg": "#383a42", - "lightFgMuted": "#a0a1a7", - "lightPurple": "#a626a4", - "lightBlue": "#4078f2", - "lightRed": "#e45649", - "lightGreen": "#50a14f", - "lightYellow": "#c18401", - "lightOrange": "#986801", - "lightCyan": "#0184bc" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#393f4a", - "light": "#d1d1d2" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#2c313a", - "light": "#e0e0e1" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "diffHighlightAdded": { - "dark": "#aad482", - "light": "#489447" - }, - "diffHighlightRemoved": { - "dark": "#e8828b", - "light": "#d65145" - }, - "diffAddedBg": { - "dark": "#2c382b", - "light": "#eafbe9" - }, - "diffRemovedBg": { - "dark": "#3a2d2f", - "light": "#fce9e8" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#495162", - "light": "#c9c9ca" - }, - "diffAddedLineNumberBg": { - "dark": "#283427", - "light": "#e1f3df" - }, - "diffRemovedLineNumberBg": { - "dark": "#36292b", - "light": "#f5e2e1" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "opencode": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#0a0a0a", - "darkStep2": "#141414", - "darkStep3": "#1e1e1e", - "darkStep4": "#282828", - "darkStep5": "#323232", - "darkStep6": "#3c3c3c", - "darkStep7": "#484848", - "darkStep8": "#606060", - "darkStep9": "#fab283", - "darkStep10": "#ffc09f", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#5c9cf5", - "darkAccent": "#9d7cd8", - "darkRed": "#e06c75", - "darkOrange": "#f5a742", - "darkGreen": "#7fd88f", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "lightStep1": "#ffffff", - "lightStep2": "#fafafa", - "lightStep3": "#f5f5f5", - "lightStep4": "#ebebeb", - "lightStep5": "#e1e1e1", - "lightStep6": "#d4d4d4", - "lightStep7": "#b8b8b8", - "lightStep8": "#a0a0a0", - "lightStep9": "#3b7dd8", - "lightStep10": "#2968c3", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#7b5bb6", - "lightAccent": "#d68c27", - "lightRed": "#d1383d", - "lightOrange": "#d68c27", - "lightGreen": "#3d9a57", - "lightCyan": "#318795", - "lightYellow": "#b0851f" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "orng": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#0a0a0a", - "darkStep2": "#141414", - "darkStep3": "#1e1e1e", - "darkStep4": "#282828", - "darkStep5": "#323232", - "darkStep6": "#3c3c3c", - "darkStep7": "#484848", - "darkStep8": "#606060", - "darkStep9": "#EC5B2B", - "darkStep10": "#EE7948", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#EE7948", - "darkAccent": "#FFF7F1", - "darkRed": "#e06c75", - "darkOrange": "#EC5B2B", - "darkBlue": "#6ba1e6", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "lightStep1": "#ffffff", - "lightStep2": "#FFF7F1", - "lightStep3": "#f5f0eb", - "lightStep4": "#ebebeb", - "lightStep5": "#e1e1e1", - "lightStep6": "#d4d4d4", - "lightStep7": "#b8b8b8", - "lightStep8": "#a0a0a0", - "lightStep9": "#EC5B2B", - "lightStep10": "#c94d24", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#EE7948", - "lightAccent": "#c94d24", - "lightRed": "#d1383d", - "lightOrange": "#EC5B2B", - "lightBlue": "#0062d1", - "lightCyan": "#318795", - "lightYellow": "#b0851f" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "selectedListItemText": { - "dark": "#0a0a0a", - "light": "#ffffff" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "#EC5B2B", - "light": "#EC5B2B" - }, - "borderActive": { - "dark": "#EE7948", - "light": "#c94d24" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#6ba1e6", - "light": "#0062d1" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#6ba1e6", - "light": "#0062d1" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#1a2a3d", - "light": "#e0edfa" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#162535", - "light": "#d0e5f5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "#EC5B2B", - "light": "#EC5B2B" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownBlockQuote": { - "dark": "#FFF7F1", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "#EE7948", - "light": "#EC5B2B" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "#EC5B2B", - "light": "#EC5B2B" - }, - "syntaxFunction": { - "dark": "#EE7948", - "light": "#c94d24" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "#FFF7F1", - "light": "#EC5B2B" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "osaka-jade": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg0": "#111c18", - "darkBg1": "#1a2520", - "darkBg2": "#23372B", - "darkBg3": "#3d4a44", - "darkFg0": "#C1C497", - "darkFg1": "#9aa88a", - "darkGray": "#53685B", - "darkRed": "#FF5345", - "darkGreen": "#549e6a", - "darkYellow": "#459451", - "darkBlue": "#509475", - "darkMagenta": "#D2689C", - "darkCyan": "#2DD5B7", - "darkWhite": "#F6F5DD", - "darkRedBright": "#db9f9c", - "darkGreenBright": "#63b07a", - "darkYellowBright": "#E5C736", - "darkBlueBright": "#ACD4CF", - "darkMagentaBright": "#75bbb3", - "darkCyanBright": "#8CD3CB", - "lightBg0": "#F6F5DD", - "lightBg1": "#E8E7CC", - "lightBg2": "#D5D4B8", - "lightBg3": "#A8A78C", - "lightFg0": "#111c18", - "lightFg1": "#1a2520", - "lightGray": "#53685B", - "lightRed": "#c7392d", - "lightGreen": "#3d7a52", - "lightYellow": "#b5a020", - "lightBlue": "#3d7560", - "lightMagenta": "#a8527a", - "lightCyan": "#1faa90" - }, - "theme": { - "primary": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "secondary": { - "dark": "darkMagenta", - "light": "lightMagenta" - }, - "accent": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "textMuted": { - "dark": "darkGray", - "light": "lightGray" - }, - "background": { - "dark": "darkBg0", - "light": "lightBg0" - }, - "backgroundPanel": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "backgroundElement": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "border": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "borderActive": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "borderSubtle": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#15241c", - "light": "#e0eee5" - }, - "diffRemovedBg": { - "dark": "#241515", - "light": "#eee0e0" - }, - "diffContextBg": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "diffLineNumber": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "diffAddedLineNumberBg": { - "dark": "#121f18", - "light": "#d5e5da" - }, - "diffRemovedLineNumberBg": { - "dark": "#1f1212", - "light": "#e5d5d5" - }, - "markdownText": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "markdownHeading": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownLink": { - "dark": "darkCyanBright", - "light": "lightCyan" - }, - "markdownLinkText": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownCode": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "darkMagenta", - "light": "lightMagenta" - }, - "markdownStrong": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "markdownHorizontalRule": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownListEnumeration": { - "dark": "darkCyanBright", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkCyanBright", - "light": "lightCyan" - }, - "markdownImageText": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownCodeBlock": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "syntaxComment": { - "dark": "darkGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "syntaxString": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkMagenta", - "light": "lightMagenta" - }, - "syntaxType": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxOperator": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxPunctuation": { - "dark": "darkFg0", - "light": "lightFg0" - } - } - }, - "palenight": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#292d3e", - "backgroundAlt": "#1e2132", - "backgroundPanel": "#32364a", - "foreground": "#a6accd", - "foregroundBright": "#bfc7d5", - "comment": "#676e95", - "red": "#f07178", - "orange": "#f78c6c", - "yellow": "#ffcb6b", - "green": "#c3e88d", - "cyan": "#89ddff", - "blue": "#82aaff", - "purple": "#c792ea", - "magenta": "#ff5370", - "pink": "#f07178" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "#4976eb" - }, - "secondary": { - "dark": "purple", - "light": "#a854f2" - }, - "accent": { - "dark": "cyan", - "light": "#00acc1" - }, - "error": { - "dark": "red", - "light": "#e53935" - }, - "warning": { - "dark": "yellow", - "light": "#ffb300" - }, - "success": { - "dark": "green", - "light": "#91b859" - }, - "info": { - "dark": "orange", - "light": "#f4511e" - }, - "text": { - "dark": "foreground", - "light": "#292d3e" - }, - "textMuted": { - "dark": "comment", - "light": "#8796b0" - }, - "background": { - "dark": "#292d3e", - "light": "#fafafa" - }, - "backgroundPanel": { - "dark": "#1e2132", - "light": "#f5f5f5" - }, - "backgroundElement": { - "dark": "#32364a", - "light": "#e7e7e8" - }, - "border": { - "dark": "#32364a", - "light": "#e0e0e0" - }, - "borderActive": { - "dark": "blue", - "light": "#4976eb" - }, - "borderSubtle": { - "dark": "#1e2132", - "light": "#eeeeee" - }, - "diffAdded": { - "dark": "green", - "light": "#91b859" - }, - "diffRemoved": { - "dark": "red", - "light": "#e53935" - }, - "diffContext": { - "dark": "comment", - "light": "#8796b0" - }, - "diffHunkHeader": { - "dark": "cyan", - "light": "#00acc1" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "#91b859" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "#e53935" - }, - "diffAddedBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "#1e2132", - "light": "#f5f5f5" - }, - "diffLineNumber": { - "dark": "#444760", - "light": "#cfd8dc" - }, - "diffAddedLineNumberBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "markdownText": { - "dark": "foreground", - "light": "#292d3e" - }, - "markdownHeading": { - "dark": "purple", - "light": "#a854f2" - }, - "markdownLink": { - "dark": "blue", - "light": "#4976eb" - }, - "markdownLinkText": { - "dark": "cyan", - "light": "#00acc1" - }, - "markdownCode": { - "dark": "green", - "light": "#91b859" - }, - "markdownBlockQuote": { - "dark": "comment", - "light": "#8796b0" - }, - "markdownEmph": { - "dark": "yellow", - "light": "#ffb300" - }, - "markdownStrong": { - "dark": "orange", - "light": "#f4511e" - }, - "markdownHorizontalRule": { - "dark": "comment", - "light": "#8796b0" - }, - "markdownListItem": { - "dark": "blue", - "light": "#4976eb" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "#00acc1" - }, - "markdownImage": { - "dark": "blue", - "light": "#4976eb" - }, - "markdownImageText": { - "dark": "cyan", - "light": "#00acc1" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#292d3e" - }, - "syntaxComment": { - "dark": "comment", - "light": "#8796b0" - }, - "syntaxKeyword": { - "dark": "purple", - "light": "#a854f2" - }, - "syntaxFunction": { - "dark": "blue", - "light": "#4976eb" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#292d3e" - }, - "syntaxString": { - "dark": "green", - "light": "#91b859" - }, - "syntaxNumber": { - "dark": "orange", - "light": "#f4511e" - }, - "syntaxType": { - "dark": "yellow", - "light": "#ffb300" - }, - "syntaxOperator": { - "dark": "cyan", - "light": "#00acc1" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#292d3e" - } - } - }, - "rosepine": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "base": "#191724", - "surface": "#1f1d2e", - "overlay": "#26233a", - "muted": "#6e6a86", - "subtle": "#908caa", - "text": "#e0def4", - "love": "#eb6f92", - "gold": "#f6c177", - "rose": "#ebbcba", - "pine": "#31748f", - "foam": "#9ccfd8", - "iris": "#c4a7e7", - "highlightLow": "#21202e", - "highlightMed": "#403d52", - "highlightHigh": "#524f67", - "moonBase": "#232136", - "moonSurface": "#2a273f", - "moonOverlay": "#393552", - "moonMuted": "#6e6a86", - "moonSubtle": "#908caa", - "moonText": "#e0def4", - "dawnBase": "#faf4ed", - "dawnSurface": "#fffaf3", - "dawnOverlay": "#f2e9e1", - "dawnMuted": "#9893a5", - "dawnSubtle": "#797593", - "dawnText": "#575279" - }, - "theme": { - "primary": { - "dark": "foam", - "light": "pine" - }, - "secondary": { - "dark": "iris", - "light": "#907aa9" - }, - "accent": { - "dark": "rose", - "light": "#d7827e" - }, - "error": { - "dark": "love", - "light": "#b4637a" - }, - "warning": { - "dark": "gold", - "light": "#ea9d34" - }, - "success": { - "dark": "pine", - "light": "#286983" - }, - "info": { - "dark": "foam", - "light": "#56949f" - }, - "text": { - "dark": "#e0def4", - "light": "#575279" - }, - "textMuted": { - "dark": "muted", - "light": "dawnMuted" - }, - "background": { - "dark": "base", - "light": "dawnBase" - }, - "backgroundPanel": { - "dark": "surface", - "light": "dawnSurface" - }, - "backgroundElement": { - "dark": "overlay", - "light": "dawnOverlay" - }, - "border": { - "dark": "highlightMed", - "light": "#dfdad9" - }, - "borderActive": { - "dark": "foam", - "light": "pine" - }, - "borderSubtle": { - "dark": "highlightLow", - "light": "#f4ede8" - }, - "diffAdded": { - "dark": "pine", - "light": "#286983" - }, - "diffRemoved": { - "dark": "love", - "light": "#b4637a" - }, - "diffContext": { - "dark": "muted", - "light": "dawnMuted" - }, - "diffHunkHeader": { - "dark": "iris", - "light": "#907aa9" - }, - "diffHighlightAdded": { - "dark": "pine", - "light": "#286983" - }, - "diffHighlightRemoved": { - "dark": "love", - "light": "#b4637a" - }, - "diffAddedBg": { - "dark": "#1f2d3a", - "light": "#e5f2f3" - }, - "diffRemovedBg": { - "dark": "#3a1f2d", - "light": "#fce5e8" - }, - "diffContextBg": { - "dark": "surface", - "light": "dawnSurface" - }, - "diffLineNumber": { - "dark": "muted", - "light": "dawnMuted" - }, - "diffAddedLineNumberBg": { - "dark": "#1f2d3a", - "light": "#e5f2f3" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1f2d", - "light": "#fce5e8" - }, - "markdownText": { - "dark": "#e0def4", - "light": "#575279" - }, - "markdownHeading": { - "dark": "iris", - "light": "#907aa9" - }, - "markdownLink": { - "dark": "foam", - "light": "pine" - }, - "markdownLinkText": { - "dark": "rose", - "light": "#d7827e" - }, - "markdownCode": { - "dark": "pine", - "light": "#286983" - }, - "markdownBlockQuote": { - "dark": "muted", - "light": "dawnMuted" - }, - "markdownEmph": { - "dark": "gold", - "light": "#ea9d34" - }, - "markdownStrong": { - "dark": "love", - "light": "#b4637a" - }, - "markdownHorizontalRule": { - "dark": "highlightMed", - "light": "#dfdad9" - }, - "markdownListItem": { - "dark": "foam", - "light": "pine" - }, - "markdownListEnumeration": { - "dark": "rose", - "light": "#d7827e" - }, - "markdownImage": { - "dark": "foam", - "light": "pine" - }, - "markdownImageText": { - "dark": "rose", - "light": "#d7827e" - }, - "markdownCodeBlock": { - "dark": "#e0def4", - "light": "#575279" - }, - "syntaxComment": { - "dark": "muted", - "light": "dawnMuted" - }, - "syntaxKeyword": { - "dark": "pine", - "light": "#286983" - }, - "syntaxFunction": { - "dark": "rose", - "light": "#d7827e" - }, - "syntaxVariable": { - "dark": "#e0def4", - "light": "#575279" - }, - "syntaxString": { - "dark": "gold", - "light": "#ea9d34" - }, - "syntaxNumber": { - "dark": "iris", - "light": "#907aa9" - }, - "syntaxType": { - "dark": "foam", - "light": "#56949f" - }, - "syntaxOperator": { - "dark": "subtle", - "light": "dawnSubtle" - }, - "syntaxPunctuation": { - "dark": "subtle", - "light": "dawnSubtle" - } - } - }, - "solarized": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "base03": "#002b36", - "base02": "#073642", - "base01": "#586e75", - "base00": "#657b83", - "base0": "#839496", - "base1": "#93a1a1", - "base2": "#eee8d5", - "base3": "#fdf6e3", - "yellow": "#b58900", - "orange": "#cb4b16", - "red": "#dc322f", - "magenta": "#d33682", - "violet": "#6c71c4", - "blue": "#268bd2", - "cyan": "#2aa198", - "green": "#859900" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "blue" - }, - "secondary": { - "dark": "violet", - "light": "violet" - }, - "accent": { - "dark": "cyan", - "light": "cyan" - }, - "error": { - "dark": "red", - "light": "red" - }, - "warning": { - "dark": "yellow", - "light": "yellow" - }, - "success": { - "dark": "green", - "light": "green" - }, - "info": { - "dark": "orange", - "light": "orange" - }, - "text": { - "dark": "base0", - "light": "base00" - }, - "textMuted": { - "dark": "base01", - "light": "base1" - }, - "background": { - "dark": "base03", - "light": "base3" - }, - "backgroundPanel": { - "dark": "base02", - "light": "base2" - }, - "backgroundElement": { - "dark": "#073642", - "light": "#eee8d5" - }, - "border": { - "dark": "base02", - "light": "base2" - }, - "borderActive": { - "dark": "base01", - "light": "base1" - }, - "borderSubtle": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffAdded": { - "dark": "green", - "light": "green" - }, - "diffRemoved": { - "dark": "red", - "light": "red" - }, - "diffContext": { - "dark": "base01", - "light": "base1" - }, - "diffHunkHeader": { - "dark": "base01", - "light": "base1" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "green" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "red" - }, - "diffAddedBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffRemovedBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffContextBg": { - "dark": "base02", - "light": "base2" - }, - "diffLineNumber": { - "dark": "base01", - "light": "base1" - }, - "diffAddedLineNumberBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffRemovedLineNumberBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "markdownText": { - "dark": "base0", - "light": "base00" - }, - "markdownHeading": { - "dark": "blue", - "light": "blue" - }, - "markdownLink": { - "dark": "cyan", - "light": "cyan" - }, - "markdownLinkText": { - "dark": "violet", - "light": "violet" - }, - "markdownCode": { - "dark": "green", - "light": "green" - }, - "markdownBlockQuote": { - "dark": "base01", - "light": "base1" - }, - "markdownEmph": { - "dark": "yellow", - "light": "yellow" - }, - "markdownStrong": { - "dark": "orange", - "light": "orange" - }, - "markdownHorizontalRule": { - "dark": "base01", - "light": "base1" - }, - "markdownListItem": { - "dark": "blue", - "light": "blue" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImage": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImageText": { - "dark": "violet", - "light": "violet" - }, - "markdownCodeBlock": { - "dark": "base0", - "light": "base00" - }, - "syntaxComment": { - "dark": "base01", - "light": "base1" - }, - "syntaxKeyword": { - "dark": "green", - "light": "green" - }, - "syntaxFunction": { - "dark": "blue", - "light": "blue" - }, - "syntaxVariable": { - "dark": "cyan", - "light": "cyan" - }, - "syntaxString": { - "dark": "cyan", - "light": "cyan" - }, - "syntaxNumber": { - "dark": "magenta", - "light": "magenta" - }, - "syntaxType": { - "dark": "yellow", - "light": "yellow" - }, - "syntaxOperator": { - "dark": "green", - "light": "green" - }, - "syntaxPunctuation": { - "dark": "base0", - "light": "base00" - } - } - }, - "synthwave84": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#262335", - "backgroundAlt": "#1e1a29", - "backgroundPanel": "#2a2139", - "foreground": "#ffffff", - "foregroundMuted": "#848bbd", - "pink": "#ff7edb", - "pinkBright": "#ff92df", - "cyan": "#36f9f6", - "cyanBright": "#72f1f8", - "yellow": "#fede5d", - "yellowBright": "#fff95d", - "orange": "#ff8b39", - "orangeBright": "#ff9f43", - "purple": "#b084eb", - "purpleBright": "#c792ea", - "red": "#fe4450", - "redBright": "#ff5e5b", - "green": "#72f1b8", - "greenBright": "#97f1d8" - }, - "theme": { - "primary": { - "dark": "cyan", - "light": "#00bcd4" - }, - "secondary": { - "dark": "pink", - "light": "#e91e63" - }, - "accent": { - "dark": "purple", - "light": "#9c27b0" - }, - "error": { - "dark": "red", - "light": "#f44336" - }, - "warning": { - "dark": "yellow", - "light": "#ff9800" - }, - "success": { - "dark": "green", - "light": "#4caf50" - }, - "info": { - "dark": "orange", - "light": "#ff5722" - }, - "text": { - "dark": "foreground", - "light": "#262335" - }, - "textMuted": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "background": { - "dark": "#262335", - "light": "#fafafa" - }, - "backgroundPanel": { - "dark": "#1e1a29", - "light": "#f5f5f5" - }, - "backgroundElement": { - "dark": "#2a2139", - "light": "#eeeeee" - }, - "border": { - "dark": "#495495", - "light": "#e0e0e0" - }, - "borderActive": { - "dark": "cyan", - "light": "#00bcd4" - }, - "borderSubtle": { - "dark": "#241b2f", - "light": "#f0f0f0" - }, - "diffAdded": { - "dark": "green", - "light": "#4caf50" - }, - "diffRemoved": { - "dark": "red", - "light": "#f44336" - }, - "diffContext": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "diffHunkHeader": { - "dark": "purple", - "light": "#9c27b0" - }, - "diffHighlightAdded": { - "dark": "greenBright", - "light": "#4caf50" - }, - "diffHighlightRemoved": { - "dark": "redBright", - "light": "#f44336" - }, - "diffAddedBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "#1e1a29", - "light": "#f5f5f5" - }, - "diffLineNumber": { - "dark": "#495495", - "light": "#b0b0b0" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "markdownText": { - "dark": "foreground", - "light": "#262335" - }, - "markdownHeading": { - "dark": "pink", - "light": "#e91e63" - }, - "markdownLink": { - "dark": "cyan", - "light": "#00bcd4" - }, - "markdownLinkText": { - "dark": "purple", - "light": "#9c27b0" - }, - "markdownCode": { - "dark": "green", - "light": "#4caf50" - }, - "markdownBlockQuote": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "markdownEmph": { - "dark": "yellow", - "light": "#ff9800" - }, - "markdownStrong": { - "dark": "orange", - "light": "#ff5722" - }, - "markdownHorizontalRule": { - "dark": "#495495", - "light": "#e0e0e0" - }, - "markdownListItem": { - "dark": "cyan", - "light": "#00bcd4" - }, - "markdownListEnumeration": { - "dark": "purple", - "light": "#9c27b0" - }, - "markdownImage": { - "dark": "cyan", - "light": "#00bcd4" - }, - "markdownImageText": { - "dark": "purple", - "light": "#9c27b0" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#262335" - }, - "syntaxComment": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "syntaxKeyword": { - "dark": "pink", - "light": "#e91e63" - }, - "syntaxFunction": { - "dark": "orange", - "light": "#ff5722" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#262335" - }, - "syntaxString": { - "dark": "yellow", - "light": "#ff9800" - }, - "syntaxNumber": { - "dark": "purple", - "light": "#9c27b0" - }, - "syntaxType": { - "dark": "cyan", - "light": "#00bcd4" - }, - "syntaxOperator": { - "dark": "pink", - "light": "#e91e63" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#262335" - } - } - }, - "tokyonight": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#1a1b26", - "darkStep2": "#1e2030", - "darkStep3": "#222436", - "darkStep4": "#292e42", - "darkStep5": "#3b4261", - "darkStep6": "#545c7e", - "darkStep7": "#737aa2", - "darkStep8": "#9099b2", - "darkStep9": "#82aaff", - "darkStep10": "#89b4fa", - "darkStep11": "#828bb8", - "darkStep12": "#c8d3f5", - "darkRed": "#ff757f", - "darkOrange": "#ff966c", - "darkYellow": "#ffc777", - "darkGreen": "#c3e88d", - "darkCyan": "#86e1fc", - "darkPurple": "#c099ff", - "lightStep1": "#e1e2e7", - "lightStep2": "#d5d6db", - "lightStep3": "#c8c9ce", - "lightStep4": "#b9bac1", - "lightStep5": "#a8aecb", - "lightStep6": "#9699a8", - "lightStep7": "#737a8c", - "lightStep8": "#5a607d", - "lightStep9": "#2e7de9", - "lightStep10": "#1a6ce7", - "lightStep11": "#8990a3", - "lightStep12": "#3760bf", - "lightRed": "#f52a65", - "lightOrange": "#b15c00", - "lightYellow": "#8c6c3e", - "lightGreen": "#587539", - "lightCyan": "#007197", - "lightPurple": "#9854f1" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "vercel": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background100": "#0A0A0A", - "background200": "#000000", - "gray100": "#1A1A1A", - "gray200": "#1F1F1F", - "gray300": "#292929", - "gray400": "#2E2E2E", - "gray500": "#454545", - "gray600": "#878787", - "gray700": "#8F8F8F", - "gray900": "#A1A1A1", - "gray1000": "#EDEDED", - "blue600": "#0099FF", - "blue700": "#0070F3", - "blue900": "#52A8FF", - "blue1000": "#EBF8FF", - "red700": "#E5484D", - "red900": "#FF6166", - "red1000": "#FDECED", - "amber700": "#FFB224", - "amber900": "#F2A700", - "amber1000": "#FDF4DC", - "green700": "#46A758", - "green900": "#63C46D", - "green1000": "#E6F9E9", - "teal700": "#12A594", - "teal900": "#0AC7AC", - "purple700": "#8E4EC6", - "purple900": "#BF7AF0", - "pink700": "#E93D82", - "pink900": "#F75590", - "highlightPink": "#FF0080", - "highlightPurple": "#F81CE5", - "cyan": "#50E3C2", - "lightBackground": "#FFFFFF", - "lightGray100": "#FAFAFA", - "lightGray200": "#EAEAEA", - "lightGray600": "#666666", - "lightGray1000": "#171717" - }, - "theme": { - "primary": { - "dark": "blue700", - "light": "blue700" - }, - "secondary": { - "dark": "blue900", - "light": "#0062D1" - }, - "accent": { - "dark": "purple700", - "light": "purple700" - }, - "error": { - "dark": "red700", - "light": "#DC3545" - }, - "warning": { - "dark": "amber700", - "light": "#FF9500" - }, - "success": { - "dark": "green700", - "light": "#388E3C" - }, - "info": { - "dark": "blue900", - "light": "blue700" - }, - "text": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "textMuted": { - "dark": "gray600", - "light": "lightGray600" - }, - "background": { - "dark": "background200", - "light": "lightBackground" - }, - "backgroundPanel": { - "dark": "gray100", - "light": "lightGray100" - }, - "backgroundElement": { - "dark": "gray300", - "light": "lightGray200" - }, - "border": { - "dark": "gray200", - "light": "lightGray200" - }, - "borderActive": { - "dark": "gray500", - "light": "#999999" - }, - "borderSubtle": { - "dark": "gray100", - "light": "#EAEAEA" - }, - "diffAdded": { - "dark": "green900", - "light": "green700" - }, - "diffRemoved": { - "dark": "red900", - "light": "red700" - }, - "diffContext": { - "dark": "gray600", - "light": "lightGray600" - }, - "diffHunkHeader": { - "dark": "gray600", - "light": "lightGray600" - }, - "diffHighlightAdded": { - "dark": "green900", - "light": "green700" - }, - "diffHighlightRemoved": { - "dark": "red900", - "light": "red700" - }, - "diffAddedBg": { - "dark": "#0B1D0F", - "light": "#E6F9E9" - }, - "diffRemovedBg": { - "dark": "#2A1314", - "light": "#FDECED" - }, - "diffContextBg": { - "dark": "background200", - "light": "lightBackground" - }, - "diffLineNumber": { - "dark": "gray600", - "light": "lightGray600" - }, - "diffAddedLineNumberBg": { - "dark": "#0F2613", - "light": "#D6F5D6" - }, - "diffRemovedLineNumberBg": { - "dark": "#3C1618", - "light": "#FFE5E5" - }, - "markdownText": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "markdownHeading": { - "dark": "purple900", - "light": "purple700" - }, - "markdownLink": { - "dark": "blue900", - "light": "blue700" - }, - "markdownLinkText": { - "dark": "teal900", - "light": "teal700" - }, - "markdownCode": { - "dark": "green900", - "light": "green700" - }, - "markdownBlockQuote": { - "dark": "gray600", - "light": "lightGray600" - }, - "markdownEmph": { - "dark": "amber900", - "light": "amber700" - }, - "markdownStrong": { - "dark": "pink900", - "light": "pink700" - }, - "markdownHorizontalRule": { - "dark": "gray500", - "light": "#999999" - }, - "markdownListItem": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "markdownListEnumeration": { - "dark": "blue900", - "light": "blue700" - }, - "markdownImage": { - "dark": "teal900", - "light": "teal700" - }, - "markdownImageText": { - "dark": "cyan", - "light": "teal700" - }, - "markdownCodeBlock": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "syntaxComment": { - "dark": "gray600", - "light": "#888888" - }, - "syntaxKeyword": { - "dark": "pink900", - "light": "pink700" - }, - "syntaxFunction": { - "dark": "purple900", - "light": "purple700" - }, - "syntaxVariable": { - "dark": "blue900", - "light": "blue700" - }, - "syntaxString": { - "dark": "green900", - "light": "green700" - }, - "syntaxNumber": { - "dark": "amber900", - "light": "amber700" - }, - "syntaxType": { - "dark": "teal900", - "light": "teal700" - }, - "syntaxOperator": { - "dark": "pink900", - "light": "pink700" - }, - "syntaxPunctuation": { - "dark": "gray1000", - "light": "lightGray1000" - } - } - }, - "vesper": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "vesperBg": "#101010", - "vesperFg": "#FFF", - "vesperComment": "#8b8b8b", - "vesperKeyword": "#A0A0A0", - "vesperFunction": "#FFC799", - "vesperString": "#99FFE4", - "vesperNumber": "#FFC799", - "vesperError": "#FF8080", - "vesperWarning": "#FFC799", - "vesperSuccess": "#99FFE4", - "vesperMuted": "#A0A0A0" - }, - "theme": { - "primary": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "secondary": { - "dark": "#99FFE4", - "light": "#99FFE4" - }, - "accent": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "error": { - "dark": "vesperError", - "light": "vesperError" - }, - "warning": { - "dark": "vesperWarning", - "light": "vesperWarning" - }, - "success": { - "dark": "vesperSuccess", - "light": "vesperSuccess" - }, - "info": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "text": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "textMuted": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "background": { - "dark": "vesperBg", - "light": "#FFF" - }, - "backgroundPanel": { - "dark": "vesperBg", - "light": "#F0F0F0" - }, - "backgroundElement": { - "dark": "vesperBg", - "light": "#E0E0E0" - }, - "border": { - "dark": "#282828", - "light": "#D0D0D0" - }, - "borderActive": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "borderSubtle": { - "dark": "#1C1C1C", - "light": "#E8E8E8" - }, - "diffAdded": { - "dark": "vesperSuccess", - "light": "vesperSuccess" - }, - "diffRemoved": { - "dark": "vesperError", - "light": "vesperError" - }, - "diffContext": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "diffHunkHeader": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "diffHighlightAdded": { - "dark": "vesperSuccess", - "light": "vesperSuccess" - }, - "diffHighlightRemoved": { - "dark": "vesperError", - "light": "vesperError" - }, - "diffAddedBg": { - "dark": "#0d2818", - "light": "#e8f5e8" - }, - "diffRemovedBg": { - "dark": "#281a1a", - "light": "#f5e8e8" - }, - "diffContextBg": { - "dark": "vesperBg", - "light": "#F8F8F8" - }, - "diffLineNumber": { - "dark": "#505050", - "light": "#808080" - }, - "diffAddedLineNumberBg": { - "dark": "#0d2818", - "light": "#e8f5e8" - }, - "diffRemovedLineNumberBg": { - "dark": "#281a1a", - "light": "#f5e8e8" - }, - "markdownText": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownHeading": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "markdownLink": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "markdownLinkText": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "markdownCode": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "markdownBlockQuote": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownEmph": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownStrong": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownHorizontalRule": { - "dark": "#65737E", - "light": "#65737E" - }, - "markdownListItem": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownListEnumeration": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownImage": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "markdownImageText": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "markdownCodeBlock": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "syntaxComment": { - "dark": "vesperComment", - "light": "vesperComment" - }, - "syntaxKeyword": { - "dark": "vesperKeyword", - "light": "vesperKeyword" - }, - "syntaxFunction": { - "dark": "vesperFunction", - "light": "vesperFunction" - }, - "syntaxVariable": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "syntaxString": { - "dark": "vesperString", - "light": "vesperString" - }, - "syntaxNumber": { - "dark": "vesperNumber", - "light": "vesperNumber" - }, - "syntaxType": { - "dark": "vesperFunction", - "light": "vesperFunction" - }, - "syntaxOperator": { - "dark": "vesperKeyword", - "light": "vesperKeyword" - }, - "syntaxPunctuation": { - "dark": "vesperFg", - "light": "vesperBg" - } - } - }, - "zenburn": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "bg": "#3f3f3f", - "bgAlt": "#4f4f4f", - "bgPanel": "#5f5f5f", - "fg": "#dcdccc", - "fgMuted": "#9f9f9f", - "red": "#cc9393", - "redBright": "#dca3a3", - "green": "#7f9f7f", - "greenBright": "#8fb28f", - "yellow": "#f0dfaf", - "yellowDim": "#e0cf9f", - "blue": "#8cd0d3", - "blueDim": "#7cb8bb", - "magenta": "#dc8cc3", - "cyan": "#93e0e3", - "orange": "#dfaf8f" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "#5f7f8f" - }, - "secondary": { - "dark": "magenta", - "light": "#8f5f8f" - }, - "accent": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "error": { - "dark": "red", - "light": "#8f5f5f" - }, - "warning": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "success": { - "dark": "green", - "light": "#5f8f5f" - }, - "info": { - "dark": "orange", - "light": "#8f7f5f" - }, - "text": { - "dark": "fg", - "light": "#3f3f3f" - }, - "textMuted": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "background": { - "dark": "bg", - "light": "#ffffef" - }, - "backgroundPanel": { - "dark": "bgAlt", - "light": "#f5f5e5" - }, - "backgroundElement": { - "dark": "bgPanel", - "light": "#ebebdb" - }, - "border": { - "dark": "#5f5f5f", - "light": "#d0d0c0" - }, - "borderActive": { - "dark": "blue", - "light": "#5f7f8f" - }, - "borderSubtle": { - "dark": "#4f4f4f", - "light": "#e0e0d0" - }, - "diffAdded": { - "dark": "green", - "light": "#5f8f5f" - }, - "diffRemoved": { - "dark": "red", - "light": "#8f5f5f" - }, - "diffContext": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "diffHunkHeader": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "diffHighlightAdded": { - "dark": "greenBright", - "light": "#5f8f5f" - }, - "diffHighlightRemoved": { - "dark": "redBright", - "light": "#8f5f5f" - }, - "diffAddedBg": { - "dark": "#4f5f4f", - "light": "#efffef" - }, - "diffRemovedBg": { - "dark": "#5f4f4f", - "light": "#ffefef" - }, - "diffContextBg": { - "dark": "bgAlt", - "light": "#f5f5e5" - }, - "diffLineNumber": { - "dark": "#6f6f6f", - "light": "#b0b0a0" - }, - "diffAddedLineNumberBg": { - "dark": "#4f5f4f", - "light": "#efffef" - }, - "diffRemovedLineNumberBg": { - "dark": "#5f4f4f", - "light": "#ffefef" - }, - "markdownText": { - "dark": "fg", - "light": "#3f3f3f" - }, - "markdownHeading": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "markdownLink": { - "dark": "blue", - "light": "#5f7f8f" - }, - "markdownLinkText": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "markdownCode": { - "dark": "green", - "light": "#5f8f5f" - }, - "markdownBlockQuote": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "markdownEmph": { - "dark": "yellowDim", - "light": "#8f8f5f" - }, - "markdownStrong": { - "dark": "orange", - "light": "#8f7f5f" - }, - "markdownHorizontalRule": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "markdownListItem": { - "dark": "blue", - "light": "#5f7f8f" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "markdownImage": { - "dark": "blue", - "light": "#5f7f8f" - }, - "markdownImageText": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "markdownCodeBlock": { - "dark": "fg", - "light": "#3f3f3f" - }, - "syntaxComment": { - "dark": "#7f9f7f", - "light": "#5f7f5f" - }, - "syntaxKeyword": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "syntaxFunction": { - "dark": "blue", - "light": "#5f7f8f" - }, - "syntaxVariable": { - "dark": "fg", - "light": "#3f3f3f" - }, - "syntaxString": { - "dark": "red", - "light": "#8f5f5f" - }, - "syntaxNumber": { - "dark": "greenBright", - "light": "#5f8f5f" - }, - "syntaxType": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "syntaxOperator": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "syntaxPunctuation": { - "dark": "fg", - "light": "#3f3f3f" - } - } - } -} diff --git a/factory/packages/cli/src/tmux.ts b/factory/packages/cli/src/tmux.ts deleted file mode 100644 index 1644133..0000000 --- a/factory/packages/cli/src/tmux.ts +++ /dev/null @@ -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" }; -} diff --git a/factory/packages/cli/src/tui.ts b/factory/packages/cli/src/tui.ts deleted file mode 100644 index b08bb81..0000000 --- a/factory/packages/cli/src/tui.ts +++ /dev/null @@ -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) => Promise; - 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 { - 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 | 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 => { - 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((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, - success: string - ): Promise => { - 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; -} diff --git a/factory/packages/cli/src/workspace/config.ts b/factory/packages/cli/src/workspace/config.ts deleted file mode 100644 index aee9999..0000000 --- a/factory/packages/cli/src/workspace/config.ts +++ /dev/null @@ -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); -} diff --git a/factory/packages/cli/test/backend-manager.test.ts b/factory/packages/cli/test/backend-manager.test.ts deleted file mode 100644 index 64cedde..0000000 --- a/factory/packages/cli/test/backend-manager.test.ts +++ /dev/null @@ -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("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 } { - return { - ok: true, - json: async () => ({ - runtime: "rivetkit", - actorNames: { - workspace: {} - } - }) - }; -} - -function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise } { - 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 }>>() - .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 }>>() - .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"); - }); -}); diff --git a/factory/packages/cli/test/task-editor.test.ts b/factory/packages/cli/test/task-editor.test.ts deleted file mode 100644 index 32321b7..0000000 --- a/factory/packages/cli/test/task-editor.test.ts +++ /dev/null @@ -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(""); - }); -}); - diff --git a/factory/packages/cli/test/theme.test.ts b/factory/packages/cli/test/theme.test.ts deleted file mode 100644 index 608426d..0000000 --- a/factory/packages/cli/test/theme.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/factory/packages/cli/test/tmux.test.ts b/factory/packages/cli/test/tmux.test.ts deleted file mode 100644 index 29b7801..0000000 --- a/factory/packages/cli/test/tmux.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/factory/packages/cli/test/tui-format.test.ts b/factory/packages/cli/test/tui-format.test.ts deleted file mode 100644 index 79020e6..0000000 --- a/factory/packages/cli/test/tui-format.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/factory/packages/cli/test/workspace-config.test.ts b/factory/packages/cli/test/workspace-config.test.ts deleted file mode 100644 index 86cdc42..0000000 --- a/factory/packages/cli/test/workspace-config.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ConfigSchema } from "@sandbox-agent/factory-shared"; -import { resolveWorkspace } from "../src/workspace/config.js"; - -describe("cli workspace resolution", () => { - it("uses default workspace when no flag", () => { - const config = ConfigSchema.parse({ - auto_submit: true as const, - notify: ["terminal" as const], - workspace: { default: "team" }, - 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" } - } - }); - - expect(resolveWorkspace(undefined, config)).toBe("team"); - expect(resolveWorkspace("alpha", config)).toBe("alpha"); - }); -}); diff --git a/factory/packages/cli/tsconfig.json b/factory/packages/cli/tsconfig.json deleted file mode 100644 index ae5ba21..0000000 --- a/factory/packages/cli/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test"] -} diff --git a/factory/packages/cli/tsup.config.ts b/factory/packages/cli/tsup.config.ts deleted file mode 100644 index 70b7806..0000000 --- a/factory/packages/cli/tsup.config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { defineConfig } from "tsup"; - -function packageVersion(): string { - try { - const packageJsonPath = resolve(process.cwd(), "package.json"); - const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; - if (typeof parsed.version === "string" && parsed.version.trim()) { - return parsed.version.trim(); - } - } catch { - // Fall through. - } - return "dev"; -} - -function sourceId(): string { - try { - const raw = execSync("git rev-parse --short HEAD", { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] - }).trim(); - if (raw.length > 0) { - return raw; - } - } catch { - // Fall through. - } - return packageVersion(); -} - -function resolveBuildId(): string { - const override = process.env.HF_BUILD_ID?.trim(); - if (override) { - return override; - } - - // Match sandbox-agent semantics: source id + unique build timestamp. - return `${sourceId()}-${Date.now().toString()}`; -} - -const buildId = resolveBuildId(); - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - dts: true, - define: { - __HF_BUILD_ID__: JSON.stringify(buildId) - } -}); - diff --git a/factory/packages/client/src/app-client.ts b/factory/packages/client/src/app-client.ts index ff78889..09e5d8a 100644 --- a/factory/packages/client/src/app-client.ts +++ b/factory/packages/client/src/app-client.ts @@ -16,8 +16,9 @@ export interface FactoryAppClient { signOut(): Promise; selectOrganization(organizationId: string): Promise; updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise; - triggerRepoImport(organizationId: string): Promise; + triggerGithubSync(organizationId: string): Promise; completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise; + openBillingPortal(organizationId: string): Promise; cancelScheduledRenewal(organizationId: string): Promise; resumeSubscription(organizationId: string): Promise; reconnectGithub(organizationId: string): Promise; @@ -62,4 +63,3 @@ export function eligibleFactoryOrganizations(snapshot: FactoryAppSnapshot): Fact const eligible = new Set(user.eligibleOrganizationIds); return snapshot.organizations.filter((organization) => eligible.has(organization.id)); } - diff --git a/factory/packages/client/src/backend-client.ts b/factory/packages/client/src/backend-client.ts index 7e97486..996a088 100644 --- a/factory/packages/client/src/backend-client.ts +++ b/factory/packages/client/src/backend-client.ts @@ -5,21 +5,21 @@ import type { AppConfig, FactoryAppSnapshot, FactoryBillingPlanId, - CreateHandoffInput, - HandoffRecord, - HandoffSummary, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + CreateTaskInput, + TaskRecord, + TaskSummary, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, HistoryEvent, HistoryQueryInput, ProviderId, @@ -32,7 +32,7 @@ import type { } from "@sandbox-agent/factory-shared"; import { sandboxInstanceKey, workspaceKey } from "./keys.js"; -export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill"; +export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill"; type RivetMetadataResponse = { runtime?: string; @@ -65,35 +65,35 @@ export interface SandboxSessionEventRecord { interface WorkspaceHandle { addRepo(input: AddRepoInput): Promise; listRepos(input: { workspaceId: string }): Promise; - createHandoff(input: CreateHandoffInput): Promise; - listHandoffs(input: { workspaceId: string; repoId?: string }): Promise; + createTask(input: CreateTaskInput): Promise; + listTasks(input: { workspaceId: string; repoId?: string }): Promise; getRepoOverview(input: { workspaceId: string; repoId: string }): Promise; runRepoStackAction(input: RepoStackActionInput): Promise; history(input: HistoryQueryInput): Promise; - switchHandoff(handoffId: string): Promise; - getHandoff(input: { workspaceId: string; handoffId: string }): Promise; - attachHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; - pushHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - syncHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - mergeHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - archiveHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - killHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; + switchTask(taskId: string): Promise; + getTask(input: { workspaceId: string; taskId: string }): Promise; + attachTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; + pushTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + syncTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + mergeTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + archiveTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>; - getWorkbench(input: { workspaceId: string }): Promise; - createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; - markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise; - renameWorkbenchHandoff(input: HandoffWorkbenchRenameInput): Promise; - renameWorkbenchBranch(input: HandoffWorkbenchRenameInput): Promise; - createWorkbenchSession(input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; - renameWorkbenchSession(input: HandoffWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise; - updateWorkbenchDraft(input: HandoffWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(input: HandoffWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(input: HandoffWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(input: HandoffWorkbenchTabInput): Promise; - closeWorkbenchSession(input: HandoffWorkbenchTabInput): Promise; - publishWorkbenchPr(input: HandoffWorkbenchSelectInput): Promise; - revertWorkbenchFile(input: HandoffWorkbenchDiffInput): Promise; + getWorkbench(input: { workspaceId: string }): Promise; + createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise; + markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise; + renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise; + renameWorkbenchBranch(input: TaskWorkbenchRenameInput): Promise; + createWorkbenchSession(input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; + renameWorkbenchSession(input: TaskWorkbenchRenameSessionInput): Promise; + setWorkbenchSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise; + updateWorkbenchDraft(input: TaskWorkbenchUpdateDraftInput): Promise; + changeWorkbenchModel(input: TaskWorkbenchChangeModelInput): Promise; + sendWorkbenchMessage(input: TaskWorkbenchSendMessageInput): Promise; + stopWorkbenchSession(input: TaskWorkbenchTabInput): Promise; + closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise; + publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise; + revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise; } interface SandboxInstanceHandle { @@ -129,27 +129,29 @@ export interface BackendMetadata { export interface BackendClient { getAppSnapshot(): Promise; - signInWithGithub(userId?: string): Promise; + signInWithGithub(): Promise; signOutApp(): Promise; selectAppOrganization(organizationId: string): Promise; updateAppOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise; triggerAppRepoImport(organizationId: string): Promise; - reconnectAppGithub(organizationId: string): Promise; - completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise; + reconnectAppGithub(organizationId: string): Promise; + completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise; + openAppBillingPortal(organizationId: string): Promise; cancelAppScheduledRenewal(organizationId: string): Promise; resumeAppSubscription(organizationId: string): Promise; recordAppSeatUsage(workspaceId: string): Promise; addRepo(workspaceId: string, remoteUrl: string): Promise; listRepos(workspaceId: string): Promise; - createHandoff(input: CreateHandoffInput): Promise; - listHandoffs(workspaceId: string, repoId?: string): Promise; + createTask(input: CreateTaskInput): Promise; + listTasks(workspaceId: string, repoId?: string): Promise; getRepoOverview(workspaceId: string, repoId: string): Promise; runRepoStackAction(input: RepoStackActionInput): Promise; - getHandoff(workspaceId: string, handoffId: string): Promise; + getTask(workspaceId: string, taskId: string): Promise; listHistory(input: HistoryQueryInput): Promise; - switchHandoff(workspaceId: string, handoffId: string): Promise; - attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }>; - runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise; + switchTask(workspaceId: string, taskId: string): Promise; + attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>; + runAction(workspaceId: string, taskId: string, action: TaskAction): Promise; + runTaskAction(workspaceId: string, taskId: string, action: TaskAction): Promise; createSandboxSession(input: { workspaceId: string; providerId: ProviderId; @@ -189,31 +191,31 @@ export interface BackendClient { providerId: ProviderId, sandboxId: string ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; - getWorkbench(workspaceId: string): Promise; + getWorkbench(workspaceId: string): Promise; subscribeWorkbench(workspaceId: string, listener: () => void): () => void; - createWorkbenchHandoff( + createWorkbenchTask( workspaceId: string, - input: HandoffWorkbenchCreateHandoffInput - ): Promise; - markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise; - renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise; - renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise; + input: TaskWorkbenchCreateTaskInput + ): Promise; + markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise; + renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise; + renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise; createWorkbenchSession( workspaceId: string, - input: HandoffWorkbenchSelectInput & { model?: string } + input: TaskWorkbenchSelectInput & { model?: string } ): Promise<{ tabId: string }>; - renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise; + renameWorkbenchSession(workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise; setWorkbenchSessionUnread( workspaceId: string, - input: HandoffWorkbenchSetSessionUnreadInput + input: TaskWorkbenchSetSessionUnreadInput ): Promise; - updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise; - closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise; - publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise; - revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise; + updateWorkbenchDraft(workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise; + changeWorkbenchModel(workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise; + sendWorkbenchMessage(workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise; + stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise; + closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise; + publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise; + revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise; health(): Promise<{ ok: true }>; useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>; } @@ -384,6 +386,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } }; + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + const sessionFromUrl = url.searchParams.get("factorySession"); + if (sessionFromUrl) { + persistAppSessionId(sessionFromUrl); + url.searchParams.delete("factorySession"); + window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); + } + } + const appRequest = async (path: string, init?: RequestInit): Promise => { const headers = new Headers(init?.headers); if (appSessionId) { @@ -396,6 +408,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const res = await fetch(`${options.endpoint.replace(/\/$/, "")}${path}`, { ...init, headers, + credentials: "include", }); const nextSessionId = res.headers.get("x-factory-session"); if (nextSessionId) { @@ -407,6 +420,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await res.json()) as T; }; + const redirectTo = async (path: string, init?: RequestInit): Promise => { + const response = await appRequest<{ url: string }>(path, init); + if (typeof window !== "undefined") { + window.location.assign(response.url); + } + }; + const getClient = async (): Promise => { if (clientPromise) { return clientPromise; @@ -473,18 +493,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return message.includes("Actor not found"); } - const sandboxByActorIdFromHandoff = async ( + const sandboxByActorIdFromTask = async ( workspaceId: string, providerId: ProviderId, sandboxId: string ): Promise => { const ws = await workspace(workspaceId); - const rows = await ws.listHandoffs({ workspaceId }); + const rows = await ws.listTasks({ workspaceId }); const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt); for (const row of candidates) { try { - const detail = await ws.getHandoff({ workspaceId, handoffId: row.handoffId }); + const detail = await ws.getTask({ workspaceId, taskId: row.taskId }); if (detail.providerId !== providerId) { continue; } @@ -500,10 +520,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (!isActorNotFoundError(error) && !message.includes("Unknown handoff")) { + if (!isActorNotFoundError(error) && !message.includes("Unknown task")) { throw error; } - // Best effort fallback path; ignore missing handoff actors here. + // Best effort fallback path; ignore missing task actors here. } } @@ -523,7 +543,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien if (!isActorNotFoundError(error)) { throw error; } - const fallback = await sandboxByActorIdFromHandoff(workspaceId, providerId, sandboxId); + const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId); if (!fallback) { throw error; } @@ -605,11 +625,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return await appRequest("/app/snapshot"); }, - async signInWithGithub(userId?: string): Promise { - return await appRequest("/app/sign-in", { - method: "POST", - body: JSON.stringify(userId ? { userId } : {}), - }); + async signInWithGithub(): Promise { + if (typeof window !== "undefined") { + window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`); + return; + } + await redirectTo("/app/auth/github/start"); }, async signOutApp(): Promise { @@ -641,22 +662,25 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }); }, - async reconnectAppGithub(organizationId: string): Promise { - return await appRequest(`/app/organizations/${organizationId}/reconnect`, { + async reconnectAppGithub(organizationId: string): Promise { + await redirectTo(`/app/organizations/${organizationId}/reconnect`, { method: "POST", }); }, - async completeAppHostedCheckout( - organizationId: string, - planId: FactoryBillingPlanId, - ): Promise { - return await appRequest(`/app/organizations/${organizationId}/billing/checkout`, { + async completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise { + await redirectTo(`/app/organizations/${organizationId}/billing/checkout`, { method: "POST", body: JSON.stringify({ planId }), }); }, + async openAppBillingPortal(organizationId: string): Promise { + await redirectTo(`/app/organizations/${organizationId}/billing/portal`, { + method: "POST", + }); + }, + async cancelAppScheduledRenewal(organizationId: string): Promise { return await appRequest(`/app/organizations/${organizationId}/billing/cancel`, { method: "POST", @@ -683,12 +707,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(workspaceId)).listRepos({ workspaceId }); }, - async createHandoff(input: CreateHandoffInput): Promise { - return (await workspace(input.workspaceId)).createHandoff(input); + async createTask(input: CreateTaskInput): Promise { + return (await workspace(input.workspaceId)).createTask(input); }, - async listHandoffs(workspaceId: string, repoId?: string): Promise { - return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId }); + async listTasks(workspaceId: string, repoId?: string): Promise { + return (await workspace(workspaceId)).listTasks({ workspaceId, repoId }); }, async getRepoOverview(workspaceId: string, repoId: string): Promise { @@ -699,10 +723,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(input.workspaceId)).runRepoStackAction(input); }, - async getHandoff(workspaceId: string, handoffId: string): Promise { - return (await workspace(workspaceId)).getHandoff({ + async getTask(workspaceId: string, taskId: string): Promise { + return (await workspace(workspaceId)).getTask({ workspaceId, - handoffId + taskId }); }, @@ -710,58 +734,62 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(input.workspaceId)).history(input); }, - async switchHandoff(workspaceId: string, handoffId: string): Promise { - return (await workspace(workspaceId)).switchHandoff(handoffId); + async switchTask(workspaceId: string, taskId: string): Promise { + return (await workspace(workspaceId)).switchTask(taskId); }, - async attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> { - return (await workspace(workspaceId)).attachHandoff({ + async attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> { + return (await workspace(workspaceId)).attachTask({ workspaceId, - handoffId, + taskId, reason: "cli.attach" }); }, - async runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise { + async runAction(workspaceId: string, taskId: string, action: TaskAction): Promise { if (action === "push") { - await (await workspace(workspaceId)).pushHandoff({ + await (await workspace(workspaceId)).pushTask({ workspaceId, - handoffId, + taskId, reason: "cli.push" }); return; } if (action === "sync") { - await (await workspace(workspaceId)).syncHandoff({ + await (await workspace(workspaceId)).syncTask({ workspaceId, - handoffId, + taskId, reason: "cli.sync" }); return; } if (action === "merge") { - await (await workspace(workspaceId)).mergeHandoff({ + await (await workspace(workspaceId)).mergeTask({ workspaceId, - handoffId, + taskId, reason: "cli.merge" }); return; } if (action === "archive") { - await (await workspace(workspaceId)).archiveHandoff({ + await (await workspace(workspaceId)).archiveTask({ workspaceId, - handoffId, + taskId, reason: "cli.archive" }); return; } - await (await workspace(workspaceId)).killHandoff({ + await (await workspace(workspaceId)).killTask({ workspaceId, - handoffId, + taskId, reason: "cli.kill" }); }, + async runTaskAction(workspaceId: string, taskId: string, action: TaskAction): Promise { + await this.runAction(workspaceId, taskId, action); + }, + async createSandboxSession(input: { workspaceId: string; providerId: ProviderId; @@ -866,7 +894,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien ); }, - async getWorkbench(workspaceId: string): Promise { + async getWorkbench(workspaceId: string): Promise { return (await workspace(workspaceId)).getWorkbench({ workspaceId }); }, @@ -874,80 +902,80 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return subscribeWorkbench(workspaceId, listener); }, - async createWorkbenchHandoff( + async createWorkbenchTask( workspaceId: string, - input: HandoffWorkbenchCreateHandoffInput - ): Promise { - return (await workspace(workspaceId)).createWorkbenchHandoff(input); + input: TaskWorkbenchCreateTaskInput + ): Promise { + return (await workspace(workspaceId)).createWorkbenchTask(input); }, - async markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + async markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await (await workspace(workspaceId)).markWorkbenchUnread(input); }, - async renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { - await (await workspace(workspaceId)).renameWorkbenchHandoff(input); + async renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise { + await (await workspace(workspaceId)).renameWorkbenchTask(input); }, - async renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { + async renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise { await (await workspace(workspaceId)).renameWorkbenchBranch(input); }, async createWorkbenchSession( workspaceId: string, - input: HandoffWorkbenchSelectInput & { model?: string } + input: TaskWorkbenchSelectInput & { model?: string } ): Promise<{ tabId: string }> { return await (await workspace(workspaceId)).createWorkbenchSession(input); }, async renameWorkbenchSession( workspaceId: string, - input: HandoffWorkbenchRenameSessionInput + input: TaskWorkbenchRenameSessionInput ): Promise { await (await workspace(workspaceId)).renameWorkbenchSession(input); }, async setWorkbenchSessionUnread( workspaceId: string, - input: HandoffWorkbenchSetSessionUnreadInput + input: TaskWorkbenchSetSessionUnreadInput ): Promise { await (await workspace(workspaceId)).setWorkbenchSessionUnread(input); }, async updateWorkbenchDraft( workspaceId: string, - input: HandoffWorkbenchUpdateDraftInput + input: TaskWorkbenchUpdateDraftInput ): Promise { await (await workspace(workspaceId)).updateWorkbenchDraft(input); }, async changeWorkbenchModel( workspaceId: string, - input: HandoffWorkbenchChangeModelInput + input: TaskWorkbenchChangeModelInput ): Promise { await (await workspace(workspaceId)).changeWorkbenchModel(input); }, async sendWorkbenchMessage( workspaceId: string, - input: HandoffWorkbenchSendMessageInput + input: TaskWorkbenchSendMessageInput ): Promise { await (await workspace(workspaceId)).sendWorkbenchMessage(input); }, - async stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + async stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise { await (await workspace(workspaceId)).stopWorkbenchSession(input); }, - async closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + async closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise { await (await workspace(workspaceId)).closeWorkbenchSession(input); }, - async publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + async publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await (await workspace(workspaceId)).publishWorkbenchPr(input); }, - async revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise { + async revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise { await (await workspace(workspaceId)).revertWorkbenchFile(input); }, diff --git a/factory/packages/client/src/keys.ts b/factory/packages/client/src/keys.ts index 5c1eae9..e58ba0e 100644 --- a/factory/packages/client/src/keys.ts +++ b/factory/packages/client/src/keys.ts @@ -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]; } diff --git a/factory/packages/client/src/mock-app.ts b/factory/packages/client/src/mock-app.ts index 2923e7e..5a73589 100644 --- a/factory/packages/client/src/mock-app.ts +++ b/factory/packages/client/src/mock-app.ts @@ -1,9 +1,9 @@ import { injectMockLatency } from "./mock/latency.js"; -export type MockBillingPlanId = "free" | "team" | "enterprise"; +export type MockBillingPlanId = "free" | "team"; export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; -export type MockRepoImportStatus = "ready" | "not_started" | "importing"; export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required"; +export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error"; export type MockOrganizationKind = "personal" | "organization"; export interface MockFactoryUser { @@ -45,8 +45,10 @@ export interface MockFactoryBillingState { export interface MockFactoryGithubState { connectedAccount: string; installationStatus: MockGithubInstallationStatus; + syncStatus: MockGithubSyncStatus; importedRepoCount: number; lastSyncLabel: string; + lastSyncAt: number | null; } export interface MockFactoryOrganizationSettings { @@ -67,7 +69,6 @@ export interface MockFactoryOrganization { billing: MockFactoryBillingState; members: MockFactoryOrganizationMember[]; seatAssignments: string[]; - repoImportStatus: MockRepoImportStatus; repoCatalog: string[]; } @@ -95,8 +96,9 @@ export interface MockFactoryAppClient { signOut(): Promise; selectOrganization(organizationId: string): Promise; updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise; - triggerRepoImport(organizationId: string): Promise; + triggerGithubSync(organizationId: string): Promise; completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise; + openBillingPortal(organizationId: string): Promise; cancelScheduledRenewal(organizationId: string): Promise; resumeSubscription(organizationId: string): Promise; reconnectGithub(organizationId: string): Promise; @@ -111,6 +113,21 @@ function isoDate(daysFromNow: number): string { return value.toISOString(); } +function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus { + switch (value) { + case "ready": + case "synced": + return "synced"; + case "importing": + case "syncing": + return "syncing"; + case "error": + return "error"; + default: + return "pending"; + } +} + function buildDefaultSnapshot(): MockFactoryAppSnapshot { return { auth: { @@ -160,8 +177,10 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { github: { connectedAccount: "nathan", installationStatus: "connected", + syncStatus: "synced", importedRepoCount: 1, lastSyncLabel: "Synced just now", + lastSyncAt: Date.now() - 60_000, }, billing: { planId: "free", @@ -177,7 +196,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { { id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" }, ], seatAssignments: ["nathan@acme.dev"], - repoImportStatus: "ready", repoCatalog: ["nathan/personal-site"], }, { @@ -195,8 +213,10 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { github: { connectedAccount: "acme", installationStatus: "connected", + syncStatus: "pending", importedRepoCount: 3, - lastSyncLabel: "Synced 4 minutes ago", + lastSyncLabel: "Waiting for first import", + lastSyncAt: null, }, billing: { planId: "team", @@ -218,7 +238,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { { id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" }, ], seatAssignments: ["nathan@acme.dev", "maya@acme.dev"], - repoImportStatus: "not_started", repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"], }, { @@ -236,18 +255,20 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { github: { connectedAccount: "rivet-dev", installationStatus: "reconnect_required", + syncStatus: "error", importedRepoCount: 4, lastSyncLabel: "Sync stalled 2 hours ago", + lastSyncAt: Date.now() - 2 * 60 * 60_000, }, billing: { - planId: "enterprise", + planId: "team", status: "trialing", - seatsIncluded: 25, + seatsIncluded: 5, trialEndsAt: isoDate(12), renewalAt: isoDate(12), - stripeCustomerId: "cus_mock_rivet_enterprise", - paymentMethodLabel: "ACH verified", - invoices: [{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], + stripeCustomerId: "cus_mock_rivet_team", + paymentMethodLabel: "Visa ending in 4242", + invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], }, members: [ { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }, @@ -255,7 +276,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { { id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" }, ], seatAssignments: ["jamie@rivet.dev"], - repoImportStatus: "not_started", repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"], }, { @@ -273,8 +293,10 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { github: { connectedAccount: "jamie", installationStatus: "connected", + syncStatus: "synced", importedRepoCount: 1, lastSyncLabel: "Synced yesterday", + lastSyncAt: Date.now() - 24 * 60 * 60_000, }, billing: { planId: "free", @@ -288,7 +310,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot { }, members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }], seatAssignments: ["jamie@rivet.dev"], - repoImportStatus: "ready", repoCatalog: ["jamie/demo-app"], }, ], @@ -306,11 +327,23 @@ function parseStoredSnapshot(): MockFactoryAppSnapshot | null { } try { - const parsed = JSON.parse(raw) as MockFactoryAppSnapshot; + const parsed = JSON.parse(raw) as MockFactoryAppSnapshot & { + organizations?: Array; + }; if (!parsed || typeof parsed !== "object") { return null; } - return parsed; + return { + ...parsed, + organizations: (parsed.organizations ?? []).map((organization: MockFactoryOrganization & { repoImportStatus?: string }) => ({ + ...organization, + github: { + ...organization.github, + syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus), + lastSyncAt: organization.github?.lastSyncAt ?? null, + }, + })), + }; } catch { return null; } @@ -330,8 +363,6 @@ function planSeatsIncluded(planId: MockBillingPlanId): number { return 1; case "team": return 5; - case "enterprise": - return 25; } } @@ -396,8 +427,8 @@ class MockFactoryAppStore implements MockFactoryAppClient { activeOrganizationId: organizationId, })); - if (org.repoImportStatus !== "ready") { - await this.triggerRepoImport(organizationId); + if (org.github.syncStatus !== "synced") { + await this.triggerGithubSync(organizationId); } } @@ -415,7 +446,7 @@ class MockFactoryAppStore implements MockFactoryAppClient { })); } - async triggerRepoImport(organizationId: string): Promise { + async triggerGithubSync(organizationId: string): Promise { await this.injectAsyncLatency(); this.requireOrganization(organizationId); const existingTimer = this.importTimers.get(organizationId); @@ -425,22 +456,23 @@ class MockFactoryAppStore implements MockFactoryAppClient { this.updateOrganization(organizationId, (organization) => ({ ...organization, - repoImportStatus: "importing", github: { ...organization.github, - lastSyncLabel: "Importing repository catalog...", + syncStatus: "syncing", + lastSyncLabel: "Syncing repositories...", }, })); const timer = setTimeout(() => { this.updateOrganization(organizationId, (organization) => ({ ...organization, - repoImportStatus: "ready", github: { ...organization.github, importedRepoCount: organization.repoCatalog.length, installationStatus: "connected", + syncStatus: "synced", lastSyncLabel: "Synced just now", + lastSyncAt: Date.now(), }, })); this.importTimers.delete(organizationId); @@ -461,13 +493,13 @@ class MockFactoryAppStore implements MockFactoryAppClient { seatsIncluded: planSeatsIncluded(planId), trialEndsAt: null, renewalAt: isoDate(30), - paymentMethodLabel: planId === "enterprise" ? "ACH verified" : "Visa ending in 4242", + paymentMethodLabel: "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, + amountUsd: planId === "team" ? 240 : 0, status: "paid", }, ...organization.billing.invoices, @@ -476,6 +508,10 @@ class MockFactoryAppStore implements MockFactoryAppClient { })); } + async openBillingPortal(_organizationId: string): Promise { + await this.injectAsyncLatency(); + } + async cancelScheduledRenewal(organizationId: string): Promise { await this.injectAsyncLatency(); this.requireOrganization(organizationId); @@ -508,7 +544,9 @@ class MockFactoryAppStore implements MockFactoryAppClient { github: { ...organization.github, installationStatus: "connected", + syncStatus: "pending", lastSyncLabel: "Reconnected just now", + lastSyncAt: Date.now(), }, })); } diff --git a/factory/packages/client/src/mock/workbench-client.ts b/factory/packages/client/src/mock/workbench-client.ts index cdeb204..682a2ce 100644 --- a/factory/packages/client/src/mock/workbench-client.ts +++ b/factory/packages/client/src/mock/workbench-client.ts @@ -1,7 +1,7 @@ import { MODEL_GROUPS, buildInitialMockLayoutViewModel, - groupWorkbenchProjects, + groupWorkbenchRepos, nowMs, providerAgent, randomReply, @@ -12,24 +12,24 @@ import { import { getMockFactoryAppClient } from "../mock-app.js"; import { injectMockLatency } from "./latency.js"; import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + TaskWorkbenchAddTabResponse, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, WorkbenchAgentTab as AgentTab, - WorkbenchHandoff as Handoff, + TaskWorkbenchSnapshot, + WorkbenchTask as Task, WorkbenchTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/factory-shared"; -import type { HandoffWorkbenchClient } from "../workbench-client.js"; +import type { TaskWorkbenchClient } from "../workbench-client.js"; function buildTranscriptEvent(params: { sessionId: string; @@ -49,8 +49,8 @@ function buildTranscriptEvent(params: { }; } -class MockWorkbenchStore implements HandoffWorkbenchClient { - private snapshot: HandoffWorkbenchSnapshot; +class MockWorkbenchStore implements TaskWorkbenchClient { + private snapshot: TaskWorkbenchSnapshot; private listeners = new Set<() => void>(); private pendingTimers = new Map>(); @@ -58,7 +58,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { this.snapshot = buildInitialMockLayoutViewModel(workspaceId); } - getSnapshot(): HandoffWorkbenchSnapshot { + getSnapshot(): TaskWorkbenchSnapshot { return this.snapshot; } @@ -69,18 +69,19 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { }; } - async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise { + async createTask(input: TaskWorkbenchCreateTaskInput): Promise { await this.injectAsyncLatency(); const id = uid(); const tabId = `session-${id}`; const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId); if (!repo) { - throw new Error(`Cannot create mock handoff for unknown repo ${input.repoId}`); + throw new Error(`Cannot create mock task for unknown repo ${input.repoId}`); } - const nextHandoff: Handoff = { + const nextTask: Task = { id, repoId: repo.id, - title: input.title?.trim() || "New Handoff", + repoIds: input.repoIds?.length ? [...new Set([repo.id, ...input.repoIds])] : [repo.id], + title: input.title?.trim() || "New Task", status: "new", repoName: repo.label, updatedAtMs: nowMs(), @@ -108,100 +109,100 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { this.updateState((current) => ({ ...current, - handoffs: [nextHandoff, ...current.handoffs], + tasks: [nextTask, ...current.tasks], })); const task = input.task.trim(); if (task) { await this.sendMessage({ - handoffId: id, + taskId: id, tabId, text: task, attachments: [], }); } - return { handoffId: id, tabId }; + return { taskId: id, tabId }; } - async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise { + async markTaskUnread(input: TaskWorkbenchSelectInput): Promise { await this.injectAsyncLatency(); - this.updateHandoff(input.handoffId, (handoff) => { - const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null; + this.updateTask(input.taskId, (task) => { + const targetTab = task.tabs[task.tabs.length - 1] ?? null; if (!targetTab) { - return handoff; + return task; } return { - ...handoff, - tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)), + ...task, + tabs: task.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)), }; }); } - async renameHandoff(input: HandoffWorkbenchRenameInput): Promise { + async renameTask(input: TaskWorkbenchRenameInput): Promise { await this.injectAsyncLatency(); const value = input.value.trim(); if (!value) { - throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`); + throw new Error(`Cannot rename task ${input.taskId} to an empty title`); } - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() })); + this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() })); } - async renameBranch(input: HandoffWorkbenchRenameInput): Promise { + async renameBranch(input: TaskWorkbenchRenameInput): Promise { await this.injectAsyncLatency(); const value = input.value.trim(); if (!value) { - throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`); + throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`); } - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() })); + this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() })); } - async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise { + async archiveTask(input: TaskWorkbenchSelectInput): Promise { await this.injectAsyncLatency(); - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() })); + this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() })); } - async publishPr(input: HandoffWorkbenchSelectInput): Promise { + async publishPr(input: TaskWorkbenchSelectInput): Promise { await this.injectAsyncLatency(); - const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1; - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, + const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1; + this.updateTask(input.taskId, (task) => ({ + ...task, updatedAtMs: nowMs(), pullRequest: { number: nextPrNumber, status: "ready" }, })); } - async pushHandoff(input: HandoffWorkbenchSelectInput): Promise { + async pushTask(input: TaskWorkbenchSelectInput): Promise { await this.injectAsyncLatency(); - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, + this.updateTask(input.taskId, (task) => ({ + ...task, updatedAtMs: nowMs(), })); } - async revertFile(input: HandoffWorkbenchDiffInput): Promise { + async revertFile(input: TaskWorkbenchDiffInput): Promise { await this.injectAsyncLatency(); - this.updateHandoff(input.handoffId, (handoff) => { - const file = handoff.fileChanges.find((entry) => entry.path === input.path); - const nextDiffs = { ...handoff.diffs }; + this.updateTask(input.taskId, (task) => { + const file = task.fileChanges.find((entry) => entry.path === input.path); + const nextDiffs = { ...task.diffs }; delete nextDiffs[input.path]; return { - ...handoff, - fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path), + ...task, + fileChanges: task.fileChanges.filter((entry) => entry.path !== input.path), diffs: nextDiffs, - fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree, + fileTree: file?.type === "A" ? removeFileTreePath(task.fileTree, input.path) : task.fileTree, }; }); } - async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise { - this.assertTab(input.handoffId, input.tabId); - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, + async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise { + this.assertTab(input.taskId, input.tabId); + this.updateTask(input.taskId, (task) => ({ + ...task, updatedAtMs: nowMs(), - tabs: handoff.tabs.map((tab) => + tabs: task.tabs.map((tab) => tab.id === input.tabId ? { ...tab, @@ -216,30 +217,30 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { })); } - async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise { + async sendMessage(input: TaskWorkbenchSendMessageInput): Promise { await this.injectAsyncLatency(); const text = input.text.trim(); if (!text) { - throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`); + throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`); } - this.assertTab(input.handoffId, input.tabId); + this.assertTab(input.taskId, input.tabId); const startedAtMs = nowMs(); getMockFactoryAppClient().recordSeatUsage(this.snapshot.workspaceId); - this.updateHandoff(input.handoffId, (currentHandoff) => { - const isFirstOnHandoff = currentHandoff.status === "new"; + this.updateTask(input.taskId, (currentTask) => { + const isFirstOnTask = currentTask.status === "new"; const synthesizedTitle = text.length > 50 ? `${text.slice(0, 47)}...` : text; const newTitle = - isFirstOnHandoff && currentHandoff.title === "New Handoff" ? synthesizedTitle : currentHandoff.title; + isFirstOnTask && currentTask.title === "New Task" ? synthesizedTitle : currentTask.title; const newBranch = - isFirstOnHandoff && !currentHandoff.branch ? `feat/${slugify(synthesizedTitle)}` : currentHandoff.branch; + isFirstOnTask && !currentTask.branch ? `feat/${slugify(synthesizedTitle)}` : currentTask.branch; const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)]; const userEvent = buildTranscriptEvent({ sessionId: input.tabId, sender: "client", createdAt: startedAtMs, - eventIndex: candidateEventIndex(currentHandoff, input.tabId), + eventIndex: candidateEventIndex(currentTask, input.tabId), payload: { method: "session/prompt", params: { @@ -249,12 +250,12 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { }); return { - ...currentHandoff, + ...currentTask, title: newTitle, branch: newBranch, status: "running", updatedAtMs: startedAtMs, - tabs: currentHandoff.tabs.map((candidate) => + tabs: currentTask.tabs.map((candidate) => candidate.id === input.tabId ? { ...candidate, @@ -276,14 +277,14 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { } const timer = setTimeout(() => { - const handoff = this.requireHandoff(input.handoffId); - const replyTab = this.requireTab(handoff, input.tabId); + const task = this.requireTask(input.taskId); + const replyTab = this.requireTab(task, input.tabId); const completedAtMs = nowMs(); const replyEvent = buildTranscriptEvent({ sessionId: input.tabId, sender: "agent", createdAt: completedAtMs, - eventIndex: candidateEventIndex(handoff, input.tabId), + eventIndex: candidateEventIndex(task, input.tabId), payload: { result: { text: randomReply(), @@ -292,8 +293,8 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { }, }); - this.updateHandoff(input.handoffId, (currentHandoff) => { - const updatedTabs = currentHandoff.tabs.map((candidate) => { + this.updateTask(input.taskId, (currentTask) => { + const updatedTabs = currentTask.tabs.map((candidate) => { if (candidate.id !== input.tabId) { return candidate; } @@ -309,10 +310,10 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); return { - ...currentHandoff, + ...currentTask, updatedAtMs: completedAtMs, tabs: updatedTabs, - status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle", + status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle", }; }); @@ -322,75 +323,75 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { this.pendingTimers.set(input.tabId, timer); } - async stopAgent(input: HandoffWorkbenchTabInput): Promise { + async stopAgent(input: TaskWorkbenchTabInput): Promise { await this.injectAsyncLatency(); - this.assertTab(input.handoffId, input.tabId); + this.assertTab(input.taskId, input.tabId); const existing = this.pendingTimers.get(input.tabId); if (existing) { clearTimeout(existing); this.pendingTimers.delete(input.tabId); } - this.updateHandoff(input.handoffId, (currentHandoff) => { - const updatedTabs = currentHandoff.tabs.map((candidate) => + this.updateTask(input.taskId, (currentTask) => { + const updatedTabs = currentTask.tabs.map((candidate) => candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate, ); const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); return { - ...currentHandoff, + ...currentTask, updatedAtMs: nowMs(), tabs: updatedTabs, - status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle", + status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle", }; }); } - async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise { + async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise { await this.injectAsyncLatency(); - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + tabs: currentTask.tabs.map((candidate) => candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate, ), })); } - async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise { + async renameSession(input: TaskWorkbenchRenameSessionInput): Promise { await this.injectAsyncLatency(); const title = input.title.trim(); if (!title) { throw new Error(`Cannot rename session ${input.tabId} to an empty title`); } - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + tabs: currentTask.tabs.map((candidate) => candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate, ), })); } - async closeTab(input: HandoffWorkbenchTabInput): Promise { + async closeTab(input: TaskWorkbenchTabInput): Promise { await this.injectAsyncLatency(); - this.updateHandoff(input.handoffId, (currentHandoff) => { - if (currentHandoff.tabs.length <= 1) { - return currentHandoff; + this.updateTask(input.taskId, (currentTask) => { + if (currentTask.tabs.length <= 1) { + return currentTask; } return { - ...currentHandoff, - tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId), + ...currentTask, + tabs: currentTask.tabs.filter((candidate) => candidate.id !== input.tabId), }; }); } - async addTab(input: HandoffWorkbenchSelectInput): Promise { + async addTab(input: TaskWorkbenchSelectInput): Promise { await this.injectAsyncLatency(); - this.assertHandoff(input.handoffId); + this.assertTask(input.taskId); const nextTab: AgentTab = { id: uid(), sessionId: null, - sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`, + sessionName: `Session ${this.requireTask(input.taskId).tabs.length + 1}`, agent: "Claude", model: "claude-sonnet-4", status: "idle", @@ -401,43 +402,44 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { transcript: [], }; - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, updatedAtMs: nowMs(), - tabs: [...currentHandoff.tabs, nextTab], + tabs: [...currentTask.tabs, nextTab], })); return { tabId: nextTab.id }; } - async changeModel(input: HandoffWorkbenchChangeModelInput): Promise { + async changeModel(input: TaskWorkbenchChangeModelInput): Promise { await this.injectAsyncLatency(); const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model)); if (!group) { throw new Error(`Unable to resolve model provider for ${input.model}`); } - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + tabs: currentTask.tabs.map((candidate) => candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate, ), })); } - private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void { + private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void { const nextSnapshot = updater(this.snapshot); this.snapshot = { ...nextSnapshot, - projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs), + repoSections: groupWorkbenchRepos(nextSnapshot.repos, nextSnapshot.tasks), + tasks: nextSnapshot.tasks, }; this.notify(); } - private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void { - this.assertHandoff(handoffId); + private updateTask(taskId: string, updater: (task: Task) => Task): void { + this.assertTask(taskId); this.updateState((current) => ({ ...current, - handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)), + tasks: current.tasks.map((task) => (task.id === taskId ? updater(task) : task)), })); } @@ -447,27 +449,27 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { } } - private assertHandoff(handoffId: string): void { - this.requireHandoff(handoffId); + private assertTask(taskId: string): void { + this.requireTask(taskId); } - private assertTab(handoffId: string, tabId: string): void { - const handoff = this.requireHandoff(handoffId); - this.requireTab(handoff, tabId); + private assertTab(taskId: string, tabId: string): void { + const task = this.requireTask(taskId); + this.requireTab(task, tabId); } - private requireHandoff(handoffId: string): Handoff { - const handoff = this.snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`Unable to find mock handoff ${handoffId}`); + private requireTask(taskId: string): Task { + const task = this.snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`Unable to find mock task ${taskId}`); } - return handoff; + return task; } - private requireTab(handoff: Handoff, tabId: string): AgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); + private requireTab(task: Task, tabId: string): AgentTab { + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (!tab) { - throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`); + throw new Error(`Unable to find mock tab ${tabId} in task ${task.id}`); } return tab; } @@ -477,14 +479,14 @@ class MockWorkbenchStore implements HandoffWorkbenchClient { } } -function candidateEventIndex(handoff: Handoff, tabId: string): number { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); +function candidateEventIndex(task: Task, tabId: string): number { + const tab = task.tabs.find((candidate) => candidate.id === tabId); return (tab?.transcript.length ?? 0) + 1; } -const mockWorkbenchClients = new Map(); +const mockWorkbenchClients = new Map(); -export function getMockWorkbenchClient(workspaceId = "default"): HandoffWorkbenchClient { +export function getMockWorkbenchClient(workspaceId = "default"): TaskWorkbenchClient { let client = mockWorkbenchClients.get(workspaceId); if (!client) { client = new MockWorkbenchStore(workspaceId); diff --git a/factory/packages/client/src/remote/app-client.ts b/factory/packages/client/src/remote/app-client.ts index 41ca300..c0a5ab8 100644 --- a/factory/packages/client/src/remote/app-client.ts +++ b/factory/packages/client/src/remote/app-client.ts @@ -20,7 +20,7 @@ class RemoteFactoryAppStore implements FactoryAppClient { }; private readonly listeners = new Set<() => void>(); private refreshPromise: Promise | null = null; - private importPollTimeout: ReturnType | null = null; + private syncPollTimeout: ReturnType | null = null; constructor(options: RemoteFactoryAppClientOptions) { this.backend = options.backend; @@ -39,9 +39,8 @@ class RemoteFactoryAppStore implements FactoryAppClient { } async signInWithGithub(userId?: string): Promise { - this.snapshot = await this.backend.signInWithGithub(userId); - this.notify(); - this.scheduleImportPollingIfNeeded(); + void userId; + await this.backend.signInWithGithub(); } async signOut(): Promise { @@ -52,7 +51,7 @@ class RemoteFactoryAppStore implements FactoryAppClient { async selectOrganization(organizationId: string): Promise { this.snapshot = await this.backend.selectAppOrganization(organizationId); this.notify(); - this.scheduleImportPollingIfNeeded(); + this.scheduleSyncPollingIfNeeded(); } async updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise { @@ -60,15 +59,18 @@ class RemoteFactoryAppStore implements FactoryAppClient { this.notify(); } - async triggerRepoImport(organizationId: string): Promise { + async triggerGithubSync(organizationId: string): Promise { this.snapshot = await this.backend.triggerAppRepoImport(organizationId); this.notify(); - this.scheduleImportPollingIfNeeded(); + this.scheduleSyncPollingIfNeeded(); } async completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise { - this.snapshot = await this.backend.completeAppHostedCheckout(organizationId, planId); - this.notify(); + await this.backend.completeAppHostedCheckout(organizationId, planId); + } + + async openBillingPortal(organizationId: string): Promise { + await this.backend.openAppBillingPortal(organizationId); } async cancelScheduledRenewal(organizationId: string): Promise { @@ -82,8 +84,7 @@ class RemoteFactoryAppStore implements FactoryAppClient { } async reconnectGithub(organizationId: string): Promise { - this.snapshot = await this.backend.reconnectAppGithub(organizationId); - this.notify(); + await this.backend.reconnectAppGithub(organizationId); } async recordSeatUsage(workspaceId: string): Promise { @@ -91,18 +92,18 @@ class RemoteFactoryAppStore implements FactoryAppClient { this.notify(); } - private scheduleImportPollingIfNeeded(): void { - if (this.importPollTimeout) { - clearTimeout(this.importPollTimeout); - this.importPollTimeout = null; + private scheduleSyncPollingIfNeeded(): void { + if (this.syncPollTimeout) { + clearTimeout(this.syncPollTimeout); + this.syncPollTimeout = null; } - if (!this.snapshot.organizations.some((organization) => organization.repoImportStatus === "importing")) { + if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) { return; } - this.importPollTimeout = setTimeout(() => { - this.importPollTimeout = null; + this.syncPollTimeout = setTimeout(() => { + this.syncPollTimeout = null; void this.refresh(); }, 500); } @@ -116,7 +117,7 @@ class RemoteFactoryAppStore implements FactoryAppClient { this.refreshPromise = (async () => { this.snapshot = await this.backend.getAppSnapshot(); this.notify(); - this.scheduleImportPollingIfNeeded(); + this.scheduleSyncPollingIfNeeded(); })().finally(() => { this.refreshPromise = null; }); diff --git a/factory/packages/client/src/remote/workbench-client.ts b/factory/packages/client/src/remote/workbench-client.ts index 3764ebe..6b65b40 100644 --- a/factory/packages/client/src/remote/workbench-client.ts +++ b/factory/packages/client/src/remote/workbench-client.ts @@ -1,31 +1,31 @@ import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + TaskWorkbenchAddTabResponse, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, + TaskWorkbenchSnapshot, } from "@sandbox-agent/factory-shared"; import type { BackendClient } from "../backend-client.js"; -import { groupWorkbenchProjects } from "../workbench-model.js"; -import type { HandoffWorkbenchClient } from "../workbench-client.js"; +import { groupWorkbenchRepos } from "../workbench-model.js"; +import type { TaskWorkbenchClient } from "../workbench-client.js"; export interface RemoteWorkbenchClientOptions { backend: BackendClient; workspaceId: string; } -class RemoteWorkbenchStore implements HandoffWorkbenchClient { +class RemoteWorkbenchStore implements TaskWorkbenchClient { private readonly backend: BackendClient; private readonly workspaceId: string; - private snapshot: HandoffWorkbenchSnapshot; + private snapshot: TaskWorkbenchSnapshot; private readonly listeners = new Set<() => void>(); private unsubscribeWorkbench: (() => void) | null = null; private refreshPromise: Promise | null = null; @@ -37,12 +37,12 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { this.snapshot = { workspaceId: options.workspaceId, repos: [], - projects: [], - handoffs: [], + repoSections: [], + tasks: [], }; } - getSnapshot(): HandoffWorkbenchSnapshot { + getSnapshot(): TaskWorkbenchSnapshot { return this.snapshot; } @@ -62,85 +62,85 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { }; } - async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise { - const created = await this.backend.createWorkbenchHandoff(this.workspaceId, input); + async createTask(input: TaskWorkbenchCreateTaskInput): Promise { + const created = await this.backend.createWorkbenchTask(this.workspaceId, input); await this.refresh(); return created; } - async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise { + async markTaskUnread(input: TaskWorkbenchSelectInput): Promise { await this.backend.markWorkbenchUnread(this.workspaceId, input); await this.refresh(); } - async renameHandoff(input: HandoffWorkbenchRenameInput): Promise { - await this.backend.renameWorkbenchHandoff(this.workspaceId, input); + async renameTask(input: TaskWorkbenchRenameInput): Promise { + await this.backend.renameWorkbenchTask(this.workspaceId, input); await this.refresh(); } - async renameBranch(input: HandoffWorkbenchRenameInput): Promise { + async renameBranch(input: TaskWorkbenchRenameInput): Promise { await this.backend.renameWorkbenchBranch(this.workspaceId, input); await this.refresh(); } - async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise { - await this.backend.runAction(this.workspaceId, input.handoffId, "archive"); + async archiveTask(input: TaskWorkbenchSelectInput): Promise { + await this.backend.runAction(this.workspaceId, input.taskId, "archive"); await this.refresh(); } - async publishPr(input: HandoffWorkbenchSelectInput): Promise { + async publishPr(input: TaskWorkbenchSelectInput): Promise { await this.backend.publishWorkbenchPr(this.workspaceId, input); await this.refresh(); } - async pushHandoff(input: HandoffWorkbenchSelectInput): Promise { - await this.backend.runAction(this.workspaceId, input.handoffId, "push"); + async pushTask(input: TaskWorkbenchSelectInput): Promise { + await this.backend.runAction(this.workspaceId, input.taskId, "push"); await this.refresh(); } - async revertFile(input: HandoffWorkbenchDiffInput): Promise { + async revertFile(input: TaskWorkbenchDiffInput): Promise { await this.backend.revertWorkbenchFile(this.workspaceId, input); await this.refresh(); } - async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise { + async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise { await this.backend.updateWorkbenchDraft(this.workspaceId, input); await this.refresh(); } - async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise { + async sendMessage(input: TaskWorkbenchSendMessageInput): Promise { await this.backend.recordAppSeatUsage(this.workspaceId); await this.backend.sendWorkbenchMessage(this.workspaceId, input); await this.refresh(); } - async stopAgent(input: HandoffWorkbenchTabInput): Promise { + async stopAgent(input: TaskWorkbenchTabInput): Promise { await this.backend.stopWorkbenchSession(this.workspaceId, input); await this.refresh(); } - async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise { + async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise { await this.backend.setWorkbenchSessionUnread(this.workspaceId, input); await this.refresh(); } - async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise { + async renameSession(input: TaskWorkbenchRenameSessionInput): Promise { await this.backend.renameWorkbenchSession(this.workspaceId, input); await this.refresh(); } - async closeTab(input: HandoffWorkbenchTabInput): Promise { + async closeTab(input: TaskWorkbenchTabInput): Promise { await this.backend.closeWorkbenchSession(this.workspaceId, input); await this.refresh(); } - async addTab(input: HandoffWorkbenchSelectInput): Promise { + async addTab(input: TaskWorkbenchSelectInput): Promise { const created = await this.backend.createWorkbenchSession(this.workspaceId, input); await this.refresh(); return created; } - async changeModel(input: HandoffWorkbenchChangeModelInput): Promise { + async changeModel(input: TaskWorkbenchChangeModelInput): Promise { await this.backend.changeWorkbenchModel(this.workspaceId, input); await this.refresh(); } @@ -185,7 +185,8 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { } this.snapshot = { ...nextSnapshot, - projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs), + repoSections: nextSnapshot.repoSections ?? groupWorkbenchRepos(nextSnapshot.repos, nextSnapshot.tasks), + tasks: nextSnapshot.tasks, }; for (const listener of [...this.listeners]) { listener(); @@ -200,6 +201,6 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { export function createRemoteWorkbenchClient( options: RemoteWorkbenchClientOptions, -): HandoffWorkbenchClient { +): TaskWorkbenchClient { return new RemoteWorkbenchStore(options); } diff --git a/factory/packages/client/src/view-model.ts b/factory/packages/client/src/view-model.ts index 99ce33a..b5c78a7 100644 --- a/factory/packages/client/src/view-model.ts +++ b/factory/packages/client/src/view-model.ts @@ -1,4 +1,4 @@ -import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared"; +import type { TaskRecord, TaskStatus } from "@sandbox-agent/factory-shared"; export const HANDOFF_STATUS_GROUPS = [ "queued", @@ -9,9 +9,9 @@ export const HANDOFF_STATUS_GROUPS = [ "error" ] as const; -export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number]; +export type TaskStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number]; -const QUEUED_STATUSES = new Set([ +const QUEUED_STATUSES = new Set([ "init_bootstrap_db", "init_enqueue_provision", "init_ensure_name", @@ -30,7 +30,7 @@ const QUEUED_STATUSES = new Set([ "kill_finalize" ]); -export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup { +export function groupTaskStatus(status: TaskStatus): TaskStatusGroup { if (status === "running") return "running"; if (status === "idle") return "idle"; if (status === "archived") return "archived"; @@ -40,7 +40,7 @@ export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup { return "queued"; } -function emptyStatusCounts(): Record { +function emptyStatusCounts(): Record { return { queued: 0, running: 0, @@ -51,9 +51,9 @@ function emptyStatusCounts(): Record { }; } -export interface HandoffSummary { +export interface TaskSummary { total: number; - byStatus: Record; + byStatus: Record; byProvider: Record; } @@ -71,7 +71,7 @@ export function fuzzyMatch(target: string, query: string): boolean { return true; } -export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRecord[] { +export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] { const q = query.trim(); if (!q) { return rows; @@ -81,7 +81,7 @@ export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRec const fields = [ row.branchName ?? "", row.title ?? "", - row.handoffId, + row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? "" @@ -101,12 +101,12 @@ export function formatRelativeAge(updatedAt: number, now = Date.now()): string { return `${days}d`; } -export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary { +export function summarizeTasks(rows: TaskRecord[]): TaskSummary { const byStatus = emptyStatusCounts(); const byProvider: Record = {}; for (const row of rows) { - byStatus[groupHandoffStatus(row.status)] += 1; + byStatus[groupTaskStatus(row.status)] += 1; byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1; } diff --git a/factory/packages/client/src/workbench-client.ts b/factory/packages/client/src/workbench-client.ts index 7f26dbf..f2a5fb5 100644 --- a/factory/packages/client/src/workbench-client.ts +++ b/factory/packages/client/src/workbench-client.ts @@ -1,63 +1,63 @@ import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + TaskWorkbenchAddTabResponse, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, } from "@sandbox-agent/factory-shared"; import type { BackendClient } from "./backend-client.js"; import { getMockWorkbenchClient } from "./mock/workbench-client.js"; import { createRemoteWorkbenchClient } from "./remote/workbench-client.js"; -export type HandoffWorkbenchClientMode = "mock" | "remote"; +export type TaskWorkbenchClientMode = "mock" | "remote"; -export interface CreateHandoffWorkbenchClientOptions { - mode: HandoffWorkbenchClientMode; +export interface CreateTaskWorkbenchClientOptions { + mode: TaskWorkbenchClientMode; backend?: BackendClient; workspaceId?: string; } -export interface HandoffWorkbenchClient { - getSnapshot(): HandoffWorkbenchSnapshot; +export interface TaskWorkbenchClient { + getSnapshot(): TaskWorkbenchSnapshot; subscribe(listener: () => void): () => void; - createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; - markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise; - renameHandoff(input: HandoffWorkbenchRenameInput): Promise; - renameBranch(input: HandoffWorkbenchRenameInput): Promise; - archiveHandoff(input: HandoffWorkbenchSelectInput): Promise; - publishPr(input: HandoffWorkbenchSelectInput): Promise; - pushHandoff(input: HandoffWorkbenchSelectInput): Promise; - revertFile(input: HandoffWorkbenchDiffInput): Promise; - updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise; - sendMessage(input: HandoffWorkbenchSendMessageInput): Promise; - stopAgent(input: HandoffWorkbenchTabInput): Promise; - setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise; - renameSession(input: HandoffWorkbenchRenameSessionInput): Promise; - closeTab(input: HandoffWorkbenchTabInput): Promise; - addTab(input: HandoffWorkbenchSelectInput): Promise; - changeModel(input: HandoffWorkbenchChangeModelInput): Promise; + createTask(input: TaskWorkbenchCreateTaskInput): Promise; + markTaskUnread(input: TaskWorkbenchSelectInput): Promise; + renameTask(input: TaskWorkbenchRenameInput): Promise; + renameBranch(input: TaskWorkbenchRenameInput): Promise; + archiveTask(input: TaskWorkbenchSelectInput): Promise; + publishPr(input: TaskWorkbenchSelectInput): Promise; + pushTask(input: TaskWorkbenchSelectInput): Promise; + revertFile(input: TaskWorkbenchDiffInput): Promise; + updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise; + sendMessage(input: TaskWorkbenchSendMessageInput): Promise; + stopAgent(input: TaskWorkbenchTabInput): Promise; + setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise; + renameSession(input: TaskWorkbenchRenameSessionInput): Promise; + closeTab(input: TaskWorkbenchTabInput): Promise; + addTab(input: TaskWorkbenchSelectInput): Promise; + changeModel(input: TaskWorkbenchChangeModelInput): Promise; } -export function createHandoffWorkbenchClient( - options: CreateHandoffWorkbenchClientOptions, -): HandoffWorkbenchClient { +export function createTaskWorkbenchClient( + options: CreateTaskWorkbenchClientOptions, +): TaskWorkbenchClient { if (options.mode === "mock") { return getMockWorkbenchClient(options.workspaceId); } if (!options.backend) { - throw new Error("Remote handoff workbench client requires a backend client"); + throw new Error("Remote task workbench client requires a backend client"); } if (!options.workspaceId) { - throw new Error("Remote handoff workbench client requires a workspace id"); + throw new Error("Remote task workbench client requires a workspace id"); } return createRemoteWorkbenchClient({ diff --git a/factory/packages/client/src/workbench-model.ts b/factory/packages/client/src/workbench-model.ts index 012385e..8d9eeec 100644 --- a/factory/packages/client/src/workbench-model.ts +++ b/factory/packages/client/src/workbench-model.ts @@ -3,13 +3,13 @@ import type { WorkbenchAgentTab as AgentTab, WorkbenchDiffLineKind as DiffLineKind, WorkbenchFileTreeNode as FileTreeNode, - WorkbenchHandoff as Handoff, - HandoffWorkbenchSnapshot, + WorkbenchTask as Task, + TaskWorkbenchSnapshot, WorkbenchHistoryEvent as HistoryEvent, WorkbenchModelGroup as ModelGroup, WorkbenchModelId as ModelId, WorkbenchParsedDiffLine as ParsedDiffLine, - WorkbenchProjectSection, + WorkbenchRepoSection, WorkbenchRepo, WorkbenchTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/factory-shared"; @@ -260,7 +260,7 @@ export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): F }); } -export function buildInitialHandoffs(): Handoff[] { +export function buildInitialTasks(): Task[] { return [ { id: "h1", @@ -913,7 +913,7 @@ export function buildInitialHandoffs(): Handoff[] { ]; } -function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: string): Handoff[] { +function buildPersonalTasks(ownerName: string, repoId: string, repoName: string): Task[] { return [ { id: "h-personal-1", @@ -950,7 +950,7 @@ function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: stri agent: "claude", createdAtMs: minutesAgo(20), lines: [ - "Updated the hero copy to focus on speed-to-handoff and clearer user outcomes.", + "Updated the hero copy to focus on speed-to-task and clearer user outcomes.", "", "I also adjusted the primary CTA to feel more action-oriented.", ], @@ -966,10 +966,10 @@ function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: stri diffs: { "src/content/home.ts": [ "@@ -1,6 +1,9 @@", - "-export const heroHeadline = 'Build AI handoffs faster';", - "+export const heroHeadline = 'Ship clean handoffs without the chaos';", + "-export const heroHeadline = 'Build AI tasks faster';", + "+export const heroHeadline = 'Ship clean tasks without the chaos';", " export const heroBody = [", - "- 'OpenHandoff keeps context, diffs, and follow-up work in one place.',", + "- 'OpenTask keeps context, diffs, and follow-up work in one place.',", "+ 'Review work, keep context, and hand tasks across your team without losing the thread.',", "+ 'Everything stays attached to the repo, the branch, and the transcript.',", " ];", @@ -1000,7 +1000,7 @@ function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: stri ]; } -function buildRivetHandoffs(): Handoff[] { +function buildRivetTasks(): Task[] { return [ { id: "rivet-h1", @@ -1092,18 +1092,18 @@ function buildRivetHandoffs(): Handoff[] { ]; } -export function buildInitialMockLayoutViewModel(workspaceId = "default"): HandoffWorkbenchSnapshot { +export function buildInitialMockLayoutViewModel(workspaceId = "default"): TaskWorkbenchSnapshot { let repos: WorkbenchRepo[]; - let handoffs: Handoff[]; + let tasks: Task[]; switch (workspaceId) { case "personal-nathan": repos = [{ id: "nathan-personal-site", label: "nathan/personal-site" }]; - handoffs = buildPersonalHandoffs("Nathan", "nathan-personal-site", "nathan/personal-site"); + tasks = buildPersonalTasks("Nathan", "nathan-personal-site", "nathan/personal-site"); break; case "personal-jamie": repos = [{ id: "jamie-demo-app", label: "jamie/demo-app" }]; - handoffs = buildPersonalHandoffs("Jamie", "jamie-demo-app", "jamie/demo-app"); + tasks = buildPersonalTasks("Jamie", "jamie-demo-app", "jamie/demo-app"); break; case "rivet": repos = [ @@ -1112,7 +1112,7 @@ export function buildInitialMockLayoutViewModel(workspaceId = "default"): Handof { id: "rivet-billing", label: "rivet/billing" }, { id: "rivet-infrastructure", label: "rivet/infrastructure" }, ]; - handoffs = buildRivetHandoffs(); + tasks = buildRivetTasks(); break; case "acme": case "default": @@ -1122,49 +1122,54 @@ export function buildInitialMockLayoutViewModel(workspaceId = "default"): Handof { id: "acme-frontend", label: "acme/frontend" }, { id: "acme-infra", label: "acme/infra" }, ]; - handoffs = buildInitialHandoffs(); + tasks = buildInitialTasks(); break; } return { workspaceId, repos, - projects: groupWorkbenchProjects(repos, handoffs), - handoffs, + repoSections: groupWorkbenchRepos(repos, tasks), + tasks, }; } -export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff[]): WorkbenchProjectSection[] { - const grouped = new Map(); +export function groupWorkbenchRepos(repos: WorkbenchRepo[], tasks: Task[]): WorkbenchRepoSection[] { + const grouped = new Map(); for (const repo of repos) { grouped.set(repo.id, { id: repo.id, label: repo.label, updatedAtMs: 0, - handoffs: [], + tasks: [], }); } - for (const handoff of handoffs) { - const existing = grouped.get(handoff.repoId) ?? { - id: handoff.repoId, - label: handoff.repoName, - updatedAtMs: 0, - handoffs: [], - }; + for (const task of tasks) { + const linkedRepoIds = task.repoIds?.length ? task.repoIds : [task.repoId]; + for (const repoId of linkedRepoIds) { + const existing = grouped.get(repoId) ?? { + id: repoId, + label: repoId === task.repoId ? task.repoName : repoId, + updatedAtMs: 0, + tasks: [], + }; - existing.handoffs.push(handoff); - existing.updatedAtMs = Math.max(existing.updatedAtMs, handoff.updatedAtMs); - grouped.set(handoff.repoId, existing); + existing.tasks.push(task); + existing.updatedAtMs = Math.max(existing.updatedAtMs, task.updatedAtMs); + grouped.set(repoId, existing); + } } return [...grouped.values()] - .map((project) => ({ - ...project, - handoffs: [...project.handoffs].sort((a, b) => b.updatedAtMs - a.updatedAtMs), + .map((repoSection) => ({ + ...repoSection, + tasks: [...repoSection.tasks].sort((a, b) => b.updatedAtMs - a.updatedAtMs), updatedAtMs: - project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs, + repoSection.tasks.length > 0 + ? Math.max(...repoSection.tasks.map((task) => task.updatedAtMs)) + : repoSection.updatedAtMs, })) .sort((a, b) => b.updatedAtMs - a.updatedAtMs); } diff --git a/factory/packages/client/test/e2e/github-pr-e2e.test.ts b/factory/packages/client/test/e2e/github-pr-e2e.test.ts index e3cbe8e..5c19058 100644 --- a/factory/packages/client/test/e2e/github-pr-e2e.test.ts +++ b/factory/packages/client/test/e2e/github-pr-e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { HandoffRecord, HistoryEvent } from "@sandbox-agent/factory-shared"; +import type { TaskRecord, HistoryEvent } from "@sandbox-agent/factory-shared"; import { createBackendClient } from "../../src/backend-client.js"; const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1"; @@ -79,20 +79,20 @@ function parseHistoryPayload(event: HistoryEvent): Record { } } -async function debugDump(client: ReturnType, workspaceId: string, handoffId: string): Promise { +async function debugDump(client: ReturnType, workspaceId: string, taskId: string): Promise { try { - const handoff = await client.getHandoff(workspaceId, handoffId); - const history = await client.listHistory({ workspaceId, handoffId, limit: 80 }).catch(() => []); + const task = await client.getTask(workspaceId, taskId); + const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []); const historySummary = history .slice(0, 20) .map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`) .join("\n"); let sessionEventsSummary = ""; - if (handoff.activeSandboxId && handoff.activeSessionId) { + if (task.activeSandboxId && task.activeSessionId) { const events = await client - .listSandboxSessionEvents(workspaceId, handoff.providerId, handoff.activeSandboxId, { - sessionId: handoff.activeSessionId, + .listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, { + sessionId: task.activeSessionId, limit: 50, }) .then((r) => r.items) @@ -104,17 +104,17 @@ async function debugDump(client: ReturnType, workspa } return [ - "=== handoff ===", + "=== task ===", JSON.stringify( { - status: handoff.status, - statusMessage: handoff.statusMessage, - title: handoff.title, - branchName: handoff.branchName, - activeSandboxId: handoff.activeSandboxId, - activeSessionId: handoff.activeSessionId, - prUrl: handoff.prUrl, - prSubmitted: handoff.prSubmitted, + status: task.status, + statusMessage: task.statusMessage, + title: task.title, + branchName: task.branchName, + activeSandboxId: task.activeSandboxId, + activeSessionId: task.activeSessionId, + prUrl: task.prUrl, + prSubmitted: task.prSubmitted, }, null, 2 @@ -144,7 +144,7 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi describe("e2e: backend -> sandbox-agent -> git -> PR", () => { it.skipIf(!RUN_E2E)( - "creates a handoff, waits for agent to implement, and opens a PR", + "creates a task, waits for agent to implement, and opens a PR", { timeout: 15 * 60_000 }, async () => { const endpoint = @@ -164,7 +164,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { const repo = await client.addRepo(workspaceId, repoRemote); - const created = await client.createHandoff({ + const created = await client.createTask({ workspaceId, repoId: repo.repoId, task: [ @@ -187,42 +187,42 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { let lastStatus: string | null = null; try { - const namedAndProvisioned = await poll( - "handoff naming + sandbox provisioning", + const namedAndProvisioned = await poll( + "task naming + sandbox provisioning", // Cold Daytona snapshot/image preparation can exceed 5 minutes on first run. 8 * 60_000, 1_000, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => Boolean(h.title && h.branchName && h.activeSandboxId), (h) => { if (h.status !== lastStatus) { lastStatus = h.status; } if (h.status === "error") { - throw new Error("handoff entered error state during provisioning"); + throw new Error("task entered error state during provisioning"); } } ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); branchName = namedAndProvisioned.branchName!; sandboxId = namedAndProvisioned.activeSandboxId!; - const withSession = await poll( - "handoff to create active session", + const withSession = await poll( + "task to create active session", 3 * 60_000, 1_500, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => Boolean(h.activeSessionId), (h) => { if (h.status === "error") { - throw new Error("handoff entered error state while waiting for active session"); + throw new Error("task entered error state while waiting for active session"); } } ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); @@ -241,23 +241,23 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { ).items, (events) => events.length > 0 ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); - await poll( - "handoff to reach idle state", + await poll( + "task to reach idle state", 8 * 60_000, 2_000, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => h.status === "idle", (h) => { if (h.status === "error") { - throw new Error("handoff entered error state while waiting for idle"); + throw new Error("task entered error state while waiting for idle"); } } ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); @@ -265,14 +265,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { "PR creation history event", 3 * 60_000, 2_000, - async () => client.listHistory({ workspaceId, handoffId: created.handoffId, limit: 200 }), - (events) => events.some((e) => e.kind === "handoff.pr_created") + async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }), + (events) => events.some((e) => e.kind === "task.pr_created") ) .catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }) - .then((events) => events.find((e) => e.kind === "handoff.pr_created")!); + .then((events) => events.find((e) => e.kind === "task.pr_created")!); const payload = parseHistoryPayload(prCreatedEvent); prNumber = Number(payload.prNumber); @@ -293,17 +293,17 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>; expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true); - // Close the handoff and assert the sandbox is released (stopped). - await client.runAction(workspaceId, created.handoffId, "archive"); + // Close the task and assert the sandbox is released (stopped). + await client.runAction(workspaceId, created.taskId, "archive"); - await poll( - "handoff to become archived (session released)", + await poll( + "task to become archived (session released)", 60_000, 1_000, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => h.status === "archived" && h.activeSessionId === null ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); @@ -318,7 +318,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { return st.includes("stopped") || st.includes("suspended") || st.includes("paused"); } ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); const state = await client .sandboxProviderState(workspaceId, "daytona", sandboxId!) .catch(() => null); diff --git a/factory/packages/client/test/e2e/workbench-e2e.test.ts b/factory/packages/client/test/e2e/workbench-e2e.test.ts index 3807955..0cf7ff3 100644 --- a/factory/packages/client/test/e2e/workbench-e2e.test.ts +++ b/factory/packages/client/test/e2e/workbench-e2e.test.ts @@ -3,10 +3,10 @@ import { mkdir, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import type { - HandoffRecord, - HandoffWorkbenchSnapshot, + TaskRecord, + TaskWorkbenchSnapshot, WorkbenchAgentTab, - WorkbenchHandoff, + WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent, } from "@sandbox-agent/factory-shared"; @@ -76,18 +76,18 @@ async function resolveBackendContainerName(endpoint: string): Promise sandbox.sandboxId === record.activeSandboxId) ?? record.sandboxes.find((sandbox) => typeof sandbox.cwd === "string" && sandbox.cwd.length > 0); const cwd = activeSandbox?.cwd?.trim(); if (!cwd) { - throw new Error(`No sandbox cwd is available for handoff ${record.handoffId}`); + throw new Error(`No sandbox cwd is available for task ${record.taskId}`); } return cwd; } -async function seedSandboxFile(endpoint: string, record: HandoffRecord, filePath: string, content: string): Promise { +async function seedSandboxFile(endpoint: string, record: TaskRecord, filePath: string, content: string): Promise { const repoPath = sandboxRepoPath(record); const containerName = await resolveBackendContainerName(endpoint); if (!containerName) { @@ -128,18 +128,18 @@ async function poll( } } -function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff { - const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`handoff ${handoffId} missing from snapshot`); +function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { + const task = snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`task ${taskId} missing from snapshot`); } - return handoff; + return task; } -function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); +function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab { + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (!tab) { - throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`); + throw new Error(`tab ${tabId} missing from task ${task.id}`); } return tab; } @@ -218,7 +218,7 @@ function transcriptIncludesAgentText( describe("e2e(client): workbench flows", () => { it.skipIf(!RUN_WORKBENCH_E2E)( - "creates a handoff, adds sessions, exchanges messages, and manages workbench state", + "creates a task, adds sessions, exchanges messages, and manages workbench state", { timeout: 20 * 60_000 }, async () => { const endpoint = @@ -237,7 +237,7 @@ describe("e2e(client): workbench flows", () => { }); const repo = await client.addRepo(workspaceId, repoRemote); - const created = await client.createWorkbenchHandoff(workspaceId, { + const created = await client.createWorkbenchTask(workspaceId, { repoId: repo.repoId, title: `Workbench E2E ${runId}`, branch: `e2e/${runId}`, @@ -246,11 +246,11 @@ describe("e2e(client): workbench flows", () => { }); const provisioned = await poll( - "handoff provisioning", + "task provisioning", 12 * 60_000, 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => handoff.branch === `e2e/${runId}` && handoff.tabs.length > 0, + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => task.branch === `e2e/${runId}` && task.tabs.length > 0, ); const primaryTab = provisioned.tabs[0]!; @@ -259,11 +259,11 @@ describe("e2e(client): workbench flows", () => { "initial agent response", 12 * 60_000, 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, primaryTab.id); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = findTab(task, primaryTab.id); return ( - handoff.status === "idle" && + task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply) ); @@ -273,41 +273,41 @@ describe("e2e(client): workbench flows", () => { expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy(); expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true); - const detail = await client.getHandoff(workspaceId, created.handoffId); + const detail = await client.getTask(workspaceId, created.taskId); await seedSandboxFile(endpoint, detail, expectedFile, runId); const fileSeeded = await poll( "seeded sandbox file reflected in workbench", 30_000, 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => handoff.fileChanges.some((file) => file.path === expectedFile), + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => task.fileChanges.some((file) => file.path === expectedFile), ); expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true); - await client.renameWorkbenchHandoff(workspaceId, { - handoffId: created.handoffId, + await client.renameWorkbenchTask(workspaceId, { + taskId: created.taskId, value: `Workbench E2E ${runId} Renamed`, }); await client.renameWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: primaryTab.id, title: "Primary Session", }); const secondTab = await client.createWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, model, }); await client.renameWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, title: "Follow-up Session", }); await client.updateWorkbenchDraft(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, text: `Reply with exactly: ${expectedReply}`, attachments: [ @@ -320,12 +320,12 @@ describe("e2e(client): workbench flows", () => { ], }); - const drafted = findHandoff(await client.getWorkbench(workspaceId), created.handoffId); + const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId); expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply); expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1); await client.sendWorkbenchMessage(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, text: `Reply with exactly: ${expectedReply}`, attachments: [], @@ -335,9 +335,9 @@ describe("e2e(client): workbench flows", () => { "follow-up session response", 10 * 60_000, 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, secondTab.tabId); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = findTab(task, secondTab.tabId); return ( tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply) @@ -349,17 +349,17 @@ describe("e2e(client): workbench flows", () => { expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true); await client.setWorkbenchSessionUnread(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, unread: false, }); - await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId }); + await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId }); - const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId); + const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId); expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true); await client.closeWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, }); @@ -367,13 +367,13 @@ describe("e2e(client): workbench flows", () => { "secondary session closed", 30_000, 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => !handoff.tabs.some((tab) => tab.id === secondTab.tabId), + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => !task.tabs.some((tab) => tab.id === secondTab.tabId), ); expect(closedSnapshot.tabs).toHaveLength(1); await client.revertWorkbenchFile(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, path: expectedFile, }); @@ -381,8 +381,8 @@ describe("e2e(client): workbench flows", () => { "file revert reflected in workbench", 30_000, 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => !handoff.fileChanges.some((file) => file.path === expectedFile), + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => !task.fileChanges.some((file) => file.path === expectedFile), ); expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false); diff --git a/factory/packages/client/test/e2e/workbench-load-e2e.test.ts b/factory/packages/client/test/e2e/workbench-load-e2e.test.ts index c1a01f6..c00e156 100644 --- a/factory/packages/client/test/e2e/workbench-load-e2e.test.ts +++ b/factory/packages/client/test/e2e/workbench-load-e2e.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import type { - HandoffWorkbenchSnapshot, + TaskWorkbenchSnapshot, WorkbenchAgentTab, - WorkbenchHandoff, + WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent, } from "@sandbox-agent/factory-shared"; @@ -70,18 +70,18 @@ async function poll( } } -function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff { - const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`handoff ${handoffId} missing from snapshot`); +function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { + const task = snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`task ${taskId} missing from snapshot`); } - return handoff; + return task; } -function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); +function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab { + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (!tab) { - throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`); + throw new Error(`tab ${tabId} missing from task ${task.id}`); } return tab; } @@ -156,12 +156,12 @@ async function measureWorkbenchSnapshot( avgMs: number; maxMs: number; payloadBytes: number; - handoffCount: number; + taskCount: number; tabCount: number; transcriptEventCount: number; }> { const durations: number[] = []; - let snapshot: HandoffWorkbenchSnapshot | null = null; + let snapshot: TaskWorkbenchSnapshot | null = null; for (let index = 0; index < iterations; index += 1) { const startedAt = performance.now(); @@ -173,13 +173,13 @@ async function measureWorkbenchSnapshot( workspaceId, repos: [], projects: [], - handoffs: [], + tasks: [], }; const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8"); - const tabCount = finalSnapshot.handoffs.reduce((sum, handoff) => sum + handoff.tabs.length, 0); - const transcriptEventCount = finalSnapshot.handoffs.reduce( - (sum, handoff) => - sum + handoff.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0), + const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0); + const transcriptEventCount = finalSnapshot.tasks.reduce( + (sum, task) => + sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0), 0, ); @@ -187,7 +187,7 @@ async function measureWorkbenchSnapshot( avgMs: Math.round(average(durations)), maxMs: Math.round(Math.max(...durations, 0)), payloadBytes, - handoffCount: finalSnapshot.handoffs.length, + taskCount: finalSnapshot.tasks.length, tabCount, transcriptEventCount, }; @@ -202,7 +202,7 @@ describe("e2e(client): workbench load", () => { const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredRepoRemote(); const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o"); - const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3); + const taskCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3); const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2); const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000); @@ -212,12 +212,12 @@ describe("e2e(client): workbench load", () => { }); const repo = await client.addRepo(workspaceId, repoRemote); - const createHandoffLatencies: number[] = []; + const createTaskLatencies: number[] = []; const provisionLatencies: number[] = []; const createSessionLatencies: number[] = []; const messageRoundTripLatencies: number[] = []; const snapshotSeries: Array<{ - handoffCount: number; + taskCount: number; avgMs: number; maxMs: number; payloadBytes: number; @@ -227,31 +227,31 @@ describe("e2e(client): workbench load", () => { snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2)); - for (let handoffIndex = 0; handoffIndex < handoffCount; handoffIndex += 1) { - const runId = `load-${handoffIndex}-${Date.now().toString(36)}`; + for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) { + const runId = `load-${taskIndex}-${Date.now().toString(36)}`; const initialReply = `LOAD_INIT_${runId}`; const createStartedAt = performance.now(); - const created = await client.createWorkbenchHandoff(workspaceId, { + const created = await client.createWorkbenchTask(workspaceId, { repoId: repo.repoId, title: `Workbench Load ${runId}`, branch: `load/${runId}`, model, task: `Reply with exactly: ${initialReply}`, }); - createHandoffLatencies.push(performance.now() - createStartedAt); + createTaskLatencies.push(performance.now() - createStartedAt); const provisionStartedAt = performance.now(); const provisioned = await poll( - `handoff ${runId} provisioning`, + `task ${runId} provisioning`, 12 * 60_000, pollIntervalMs, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = handoff.tabs[0]; + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = task.tabs[0]; return Boolean( tab && - handoff.status === "idle" && + task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply), ); @@ -267,13 +267,13 @@ describe("e2e(client): workbench load", () => { const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`; const createSessionStartedAt = performance.now(); const createdSession = await client.createWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, model, }); createSessionLatencies.push(performance.now() - createSessionStartedAt); await client.sendWorkbenchMessage(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: createdSession.tabId, text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`, attachments: [], @@ -281,12 +281,12 @@ describe("e2e(client): workbench load", () => { const messageStartedAt = performance.now(); const withReply = await poll( - `handoff ${runId} session ${sessionIndex} reply`, + `task ${runId} session ${sessionIndex} reply`, 10 * 60_000, pollIntervalMs, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, createdSession.tabId); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = findTab(task, createdSession.tabId); return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply); }, ); @@ -300,7 +300,7 @@ describe("e2e(client): workbench load", () => { console.info( "[workbench-load-snapshot]", JSON.stringify({ - handoffIndex: handoffIndex + 1, + taskIndex: taskIndex + 1, ...snapshotMetrics, }), ); @@ -309,9 +309,9 @@ describe("e2e(client): workbench load", () => { const firstSnapshot = snapshotSeries[0]!; const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!; const summary = { - handoffCount, + taskCount, extraSessionCount, - createHandoffAvgMs: Math.round(average(createHandoffLatencies)), + createTaskAvgMs: Math.round(average(createTaskLatencies)), provisionAvgMs: Math.round(average(provisionLatencies)), createSessionAvgMs: Math.round(average(createSessionLatencies)), messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)), @@ -326,10 +326,10 @@ describe("e2e(client): workbench load", () => { console.info("[workbench-load-summary]", JSON.stringify(summary)); - expect(createHandoffLatencies.length).toBe(handoffCount); - expect(provisionLatencies.length).toBe(handoffCount); - expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount); - expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount); + expect(createTaskLatencies.length).toBe(taskCount); + expect(provisionLatencies.length).toBe(taskCount); + expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount); + expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount); }, ); }); diff --git a/factory/packages/client/test/keys.test.ts b/factory/packages/client/test/keys.test.ts index 61320f8..9af2ce7 100644 --- a/factory/packages/client/test/keys.test.ts +++ b/factory/packages/client/test/keys.test.ts @@ -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/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) { diff --git a/factory/packages/client/test/view-model.test.ts b/factory/packages/client/test/view-model.test.ts index fac0ac0..e0451b0 100644 --- a/factory/packages/client/test/view-model.test.ts +++ b/factory/packages/client/test/view-model.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@sandbox-agent/factory-shared"; +import type { TaskRecord } from "@sandbox-agent/factory-shared"; import { - filterHandoffs, + filterTasks, formatRelativeAge, fuzzyMatch, - summarizeHandoffs + summarizeTasks } from "../src/view-model.js"; -const sample: HandoffRecord = { +const sample: TaskRecord = { workspaceId: "default", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", + taskId: "task-1", branchName: "feature/test", title: "Test Title", task: "Do test", @@ -53,19 +53,19 @@ describe("search helpers", () => { }); it("filters rows across branch and title", () => { - const rows: HandoffRecord[] = [ + const rows: TaskRecord[] = [ sample, { ...sample, - handoffId: "handoff-2", + taskId: "task-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); + expect(filterTasks(rows, "doc")).toHaveLength(1); + expect(filterTasks(rows, "h2")).toHaveLength(1); + expect(filterTasks(rows, "test")).toHaveLength(2); }); }); @@ -76,13 +76,13 @@ describe("summary helpers", () => { }); it("summarizes by status and provider", () => { - const rows: HandoffRecord[] = [ + const rows: TaskRecord[] = [ sample, - { ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" }, - { ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" } + { ...sample, taskId: "task-2", status: "idle", providerId: "daytona" }, + { ...sample, taskId: "task-3", status: "error", providerId: "daytona" } ]; - const summary = summarizeHandoffs(rows); + const summary = summarizeTasks(rows); expect(summary.total).toBe(3); expect(summary.byStatus.running).toBe(1); expect(summary.byStatus.idle).toBe(1); diff --git a/factory/packages/client/test/workbench-client.test.ts b/factory/packages/client/test/workbench-client.test.ts index 79d7bd0..a5161ea 100644 --- a/factory/packages/client/test/workbench-client.test.ts +++ b/factory/packages/client/test/workbench-client.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; import type { BackendClient } from "../src/backend-client.js"; -import { createHandoffWorkbenchClient } from "../src/workbench-client.js"; +import { createTaskWorkbenchClient } from "../src/workbench-client.js"; async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } -describe("createHandoffWorkbenchClient", () => { +describe("createTaskWorkbenchClient", () => { it("scopes mock clients by workspace", async () => { - const alpha = createHandoffWorkbenchClient({ + const alpha = createTaskWorkbenchClient({ mode: "mock", workspaceId: "mock-alpha", }); - const beta = createHandoffWorkbenchClient({ + const beta = createTaskWorkbenchClient({ mode: "mock", workspaceId: "mock-beta", }); @@ -22,24 +22,24 @@ describe("createHandoffWorkbenchClient", () => { expect(alphaInitial.workspaceId).toBe("mock-alpha"); expect(betaInitial.workspaceId).toBe("mock-beta"); - await alpha.createHandoff({ + await alpha.createTask({ repoId: alphaInitial.repos[0]!.id, task: "Ship alpha-only change", title: "Alpha only", }); - expect(alpha.getSnapshot().handoffs).toHaveLength(alphaInitial.handoffs.length + 1); - expect(beta.getSnapshot().handoffs).toHaveLength(betaInitial.handoffs.length); + expect(alpha.getSnapshot().tasks).toHaveLength(alphaInitial.tasks.length + 1); + expect(beta.getSnapshot().tasks).toHaveLength(betaInitial.tasks.length); }); - it("uses the initial task to bootstrap a new mock handoff session", async () => { - const client = createHandoffWorkbenchClient({ + it("uses the initial task to bootstrap a new mock task session", async () => { + const client = createTaskWorkbenchClient({ mode: "mock", workspaceId: "mock-onboarding", }); const snapshot = client.getSnapshot(); - const created = await client.createHandoff({ + const created = await client.createTask({ repoId: snapshot.repos[0]!.id, task: "Reply with exactly: MOCK_WORKBENCH_READY", title: "Mock onboarding", @@ -47,22 +47,22 @@ describe("createHandoffWorkbenchClient", () => { model: "gpt-4o", }); - const runningHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId); - expect(runningHandoff).toEqual( + const runningTask = client.getSnapshot().tasks.find((task) => task.id === created.taskId); + expect(runningTask).toEqual( expect.objectContaining({ title: "Mock onboarding", branch: "feat/mock-onboarding", status: "running", }), ); - expect(runningHandoff?.tabs[0]).toEqual( + expect(runningTask?.tabs[0]).toEqual( expect.objectContaining({ id: created.tabId, created: true, status: "running", }), ); - expect(runningHandoff?.tabs[0]?.transcript).toEqual([ + expect(runningTask?.tabs[0]?.transcript).toEqual([ expect.objectContaining({ sender: "client", payload: expect.objectContaining({ @@ -73,26 +73,26 @@ describe("createHandoffWorkbenchClient", () => { await sleep(2_700); - const completedHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId); - expect(completedHandoff?.status).toBe("idle"); - expect(completedHandoff?.tabs[0]).toEqual( + const completedTask = client.getSnapshot().tasks.find((task) => task.id === created.taskId); + expect(completedTask?.status).toBe("idle"); + expect(completedTask?.tabs[0]).toEqual( expect.objectContaining({ status: "idle", unread: true, }), ); - expect(completedHandoff?.tabs[0]?.transcript).toEqual([ + expect(completedTask?.tabs[0]?.transcript).toEqual([ expect.objectContaining({ sender: "client" }), expect.objectContaining({ sender: "agent" }), ]); }); it("routes remote push actions through the backend boundary", async () => { - const actions: Array<{ workspaceId: string; handoffId: string; action: string }> = []; + const actions: Array<{ workspaceId: string; taskId: string; action: string }> = []; let snapshotReads = 0; const backend = { - async runAction(workspaceId: string, handoffId: string, action: string): Promise { - actions.push({ workspaceId, handoffId, action }); + async runAction(workspaceId: string, taskId: string, action: string): Promise { + actions.push({ workspaceId, taskId, action }); }, async getWorkbench(workspaceId: string) { snapshotReads += 1; @@ -100,7 +100,7 @@ describe("createHandoffWorkbenchClient", () => { workspaceId, repos: [], projects: [], - handoffs: [], + tasks: [], }; }, subscribeWorkbench(): () => void { @@ -108,18 +108,18 @@ describe("createHandoffWorkbenchClient", () => { }, } as unknown as BackendClient; - const client = createHandoffWorkbenchClient({ + const client = createTaskWorkbenchClient({ mode: "remote", backend, workspaceId: "remote-ws", }); - await client.pushHandoff({ handoffId: "handoff-123" }); + await client.pushTask({ taskId: "task-123" }); expect(actions).toEqual([ { workspaceId: "remote-ws", - handoffId: "handoff-123", + taskId: "task-123", action: "push", }, ]); diff --git a/factory/packages/frontend/src/app/router.tsx b/factory/packages/frontend/src/app/router.tsx index 422631b..b73100e 100644 --- a/factory/packages/frontend/src/app/router.tsx +++ b/factory/packages/frontend/src/app/router.tsx @@ -14,7 +14,6 @@ import { MockLayout } from "../components/mock-layout"; import { MockHostedCheckoutPage, MockOrganizationBillingPage, - MockOrganizationImportPage, MockOrganizationSelectorPage, MockOrganizationSettingsPage, MockSignInPage, @@ -24,10 +23,12 @@ import { activeMockOrganization, activeMockUser, getMockOrganizationById, + isAppSnapshotBootstrapping, eligibleOrganizations, + useMockAppClient, useMockAppSnapshot, } from "../lib/mock-app"; -import { getHandoffWorkbenchClient, resolveRepoRouteHandoffId } from "../lib/workbench"; +import { getTaskWorkbenchClient, resolveRepoRouteTaskId } from "../lib/workbench"; const rootRoute = createRootRoute({ component: RootLayout, @@ -51,12 +52,6 @@ const organizationsRoute = createRoute({ component: OrganizationsRoute, }); -const organizationImportRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/organizations/$organizationId/import", - component: OrganizationImportRoute, -}); - const organizationSettingsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/organizations/$organizationId/settings", @@ -87,13 +82,13 @@ const workspaceIndexRoute = createRoute({ component: WorkspaceRoute, }); -const handoffRoute = createRoute({ +const taskRoute = createRoute({ getParentRoute: () => workspaceRoute, - path: "handoffs/$handoffId", + path: "tasks/$taskId", validateSearch: (search: Record) => ({ sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined, }), - component: HandoffRoute, + component: TaskRoute, }); const repoRoute = createRoute({ @@ -106,11 +101,10 @@ const routeTree = rootRoute.addChildren([ indexRoute, signInRoute, organizationsRoute, - organizationImportRoute, organizationSettingsRoute, organizationBillingRoute, organizationCheckoutRoute, - workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]), + workspaceRoute.addChildren([workspaceIndexRoute, taskRoute, repoRoute]), ]); export const router = createRouter({ routeTree }); @@ -132,6 +126,9 @@ function IndexRoute() { function SignInRoute() { const snapshot = useMockAppSnapshot(); + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } if (snapshot.auth.status === "signed_in") { return ; @@ -142,6 +139,9 @@ function SignInRoute() { function OrganizationsRoute() { const snapshot = useMockAppSnapshot(); + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } if (snapshot.auth.status === "signed_out") { return ; @@ -150,24 +150,12 @@ function OrganizationsRoute() { return ; } -function OrganizationImportRoute() { - const snapshot = useMockAppSnapshot(); - const organization = useGuardedMockOrganization(organizationImportRoute.useParams().organizationId); - - if (snapshot.auth.status === "signed_out") { - return ; - } - - if (!organization) { - return ; - } - - return ; -} - function OrganizationSettingsRoute() { const snapshot = useMockAppSnapshot(); const organization = useGuardedMockOrganization(organizationSettingsRoute.useParams().organizationId); + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } if (snapshot.auth.status === "signed_out") { return ; @@ -183,6 +171,9 @@ function OrganizationSettingsRoute() { function OrganizationBillingRoute() { const snapshot = useMockAppSnapshot(); const organization = useGuardedMockOrganization(organizationBillingRoute.useParams().organizationId); + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } if (snapshot.auth.status === "signed_out") { return ; @@ -199,6 +190,9 @@ function OrganizationCheckoutRoute() { const { organizationId, planId } = organizationCheckoutRoute.useParams(); const snapshot = useMockAppSnapshot(); const organization = useGuardedMockOrganization(organizationId); + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } if (snapshot.auth.status === "signed_out") { return ; @@ -226,18 +220,18 @@ function WorkspaceRoute() { return ( - + ); } -function HandoffRoute() { - const { workspaceId, handoffId } = handoffRoute.useParams(); - const { sessionId } = handoffRoute.useSearch(); +function TaskRoute() { + const { workspaceId, taskId } = taskRoute.useParams(); + const { sessionId } = taskRoute.useSearch(); return ( - + ); } @@ -253,7 +247,7 @@ function RepoRoute() { } function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) { - const client = getHandoffWorkbenchClient(workspaceId); + const client = getTaskWorkbenchClient(workspaceId); const snapshot = useSyncExternalStore( client.subscribe.bind(client), client.getSnapshot.bind(client), @@ -263,13 +257,13 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: useEffect(() => { setFrontendErrorContext({ workspaceId, - handoffId: undefined, + taskId: undefined, repoId, }); }, [repoId, workspaceId]); - const activeHandoffId = resolveRepoRouteHandoffId(snapshot, repoId); - if (!activeHandoffId) { + const activeTaskId = resolveRepoRouteTaskId(snapshot, repoId); + if (!activeTaskId) { return ( candidate.workspaceId === workspaceId) ?? null; @@ -308,16 +303,16 @@ function WorkspaceView({ useEffect(() => { setFrontendErrorContext({ workspaceId, - handoffId: selectedHandoffId ?? undefined, + taskId: selectedTaskId ?? undefined, repoId: undefined, }); - }, [selectedHandoffId, workspaceId]); + }, [selectedTaskId, workspaceId]); return ( void appClient.triggerGithubSync(organization.id) : undefined} + onReconnectGithub={organization ? () => void appClient.reconnectGithub(organization.id) : undefined} sidebarActions={ organization ? [ @@ -363,6 +361,9 @@ function MockWorkspaceGate({ children: React.ReactNode; }) { const snapshot = useMockAppSnapshot(); + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } if (snapshot.auth.status === "signed_out") { return ; @@ -379,16 +380,6 @@ function MockWorkspaceGate({ return ; } - if (workspaceOrganization.repoImportStatus !== "ready") { - return ( - - ); - } - return <>{children}; } @@ -399,6 +390,10 @@ function NavigateToMockHome({ snapshot: ReturnType; replace?: boolean; }) { + if (isAppSnapshotBootstrapping(snapshot)) { + return ; + } + const activeOrganization = activeMockOrganization(snapshot); const organizations = eligibleOrganizations(snapshot); const targetOrganization = @@ -420,16 +415,6 @@ function NavigateToMockHome({ ); } - if (targetOrganization.repoImportStatus !== "ready") { - return ( - - ); - } - return ( +
+
+ Restoring session +
+
Loading Factory state
+
+ Applying the returned app session and loading your organizations before routing deeper into Factory. +
+
+ + ); +} + function RouteContextSync() { const location = useRouterState({ select: (state) => state.location, diff --git a/factory/packages/frontend/src/components/mock-layout.tsx b/factory/packages/frontend/src/components/mock-layout.tsx index bdc64a6..27f8666 100644 --- a/factory/packages/frontend/src/components/mock-layout.tsx +++ b/factory/packages/frontend/src/components/mock-layout.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client"; +import type { TaskWorkbenchClient } from "@sandbox-agent/factory-client"; +import type { FactoryGithubState } from "@sandbox-agent/factory-shared"; import { useNavigate } from "@tanstack/react-router"; import { DiffContent } from "./mock-layout/diff-content"; @@ -18,41 +19,41 @@ import { diffTabId, formatThinkingDuration, isDiffTab, - type Handoff, + type Task, type HistoryEvent, type LineAttachment, type Message, type ModelId, } from "./mock-layout/view-model"; -function firstAgentTabId(handoff: Handoff): string | null { - return handoff.tabs[0]?.id ?? null; +function firstAgentTabId(task: Task): string | null { + return task.tabs[0]?.id ?? null; } -function sanitizeOpenDiffs(handoff: Handoff, paths: string[] | undefined): string[] { +function sanitizeOpenDiffs(task: Task, paths: string[] | undefined): string[] { if (!paths) { return []; } - return paths.filter((path) => handoff.diffs[path] != null); + return paths.filter((path) => task.diffs[path] != null); } -function sanitizeLastAgentTabId(handoff: Handoff, tabId: string | null | undefined): string | null { - if (tabId && handoff.tabs.some((tab) => tab.id === tabId)) { +function sanitizeLastAgentTabId(task: Task, tabId: string | null | undefined): string | null { + if (tabId && task.tabs.some((tab) => tab.id === tabId)) { return tabId; } - return firstAgentTabId(handoff); + return firstAgentTabId(task); } function sanitizeActiveTabId( - handoff: Handoff, + task: Task, tabId: string | null | undefined, openDiffs: string[], lastAgentTabId: string | null, ): string | null { if (tabId) { - if (handoff.tabs.some((tab) => tab.id === tabId)) { + if (task.tabs.some((tab) => tab.id === tabId)) { return tabId; } if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) { @@ -65,7 +66,7 @@ function sanitizeActiveTabId( const TranscriptPanel = memo(function TranscriptPanel({ client, - handoff, + task, activeTabId, lastAgentTabId, openDiffs, @@ -74,12 +75,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetLastAgentTabId, onSetOpenDiffs, }: { - client: HandoffWorkbenchClient; - handoff: Handoff; + client: TaskWorkbenchClient; + task: Task; activeTabId: string | null; lastAgentTabId: string | null; openDiffs: string[]; - onSyncRouteSession: (handoffId: string, sessionId: string | null, replace?: boolean) => void; + onSyncRouteSession: (taskId: string, sessionId: string | null, replace?: boolean) => void; onSetActiveTabId: (tabId: string | null) => void; onSetLastAgentTabId: (tabId: string | null) => void; onSetOpenDiffs: (paths: string[]) => void; @@ -96,10 +97,10 @@ const TranscriptPanel = memo(function TranscriptPanel({ const textareaRef = useRef(null); const messageRefs = useRef(new Map()); const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null; - const activeAgentTab = activeDiff ? null : (handoff.tabs.find((candidate) => candidate.id === activeTabId) ?? handoff.tabs[0] ?? null); - const promptTab = handoff.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? handoff.tabs[0] ?? null; - const isTerminal = handoff.status === "archived"; - const historyEvents = useMemo(() => buildHistoryEvents(handoff.tabs), [handoff.tabs]); + const activeAgentTab = activeDiff ? null : (task.tabs.find((candidate) => candidate.id === activeTabId) ?? task.tabs[0] ?? null); + const promptTab = task.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? task.tabs[0] ?? null; + const isTerminal = task.status === "archived"; + const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]); const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]); const draft = promptTab?.draft.text ?? ""; const attachments = promptTab?.draft.attachments ?? []; @@ -112,12 +113,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ useEffect(() => { textareaRef.current?.focus(); - }, [activeTabId, handoff.id]); + }, [activeTabId, task.id]); useEffect(() => { setEditingSessionTabId(null); setEditingSessionName(""); - }, [handoff.id]); + }, [task.id]); useLayoutEffect(() => { const textarea = textareaRef.current; @@ -129,7 +130,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ const nextHeight = Math.min(textarea.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT); textarea.style.height = `${Math.max(PROMPT_TEXTAREA_MIN_HEIGHT, nextHeight)}px`; textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"; - }, [draft, activeTabId, handoff.id]); + }, [draft, activeTabId, task.id]); useEffect(() => { if (!pendingHistoryTarget || activeTabId !== pendingHistoryTarget.tabId) { @@ -176,11 +177,11 @@ const TranscriptPanel = memo(function TranscriptPanel({ } void client.setSessionUnread({ - handoffId: handoff.id, + taskId: task.id, tabId: activeAgentTab.id, unread: false, }); - }, [activeAgentTab?.id, activeAgentTab?.unread, client, handoff.id]); + }, [activeAgentTab?.id, activeAgentTab?.unread, client, task.id]); const startEditingField = useCallback((field: "title" | "branch", value: string) => { setEditingField(field); @@ -200,13 +201,13 @@ const TranscriptPanel = memo(function TranscriptPanel({ } if (field === "title") { - void client.renameHandoff({ handoffId: handoff.id, value }); + void client.renameTask({ taskId: task.id, value }); } else { - void client.renameBranch({ handoffId: handoff.id, value }); + void client.renameBranch({ taskId: task.id, value }); } setEditingField(null); }, - [client, editValue, handoff.id], + [client, editValue, task.id], ); const updateDraft = useCallback( @@ -216,13 +217,13 @@ const TranscriptPanel = memo(function TranscriptPanel({ } void client.updateDraft({ - handoffId: handoff.id, + taskId: task.id, tabId: promptTab.id, text: nextText, attachments: nextAttachments, }); }, - [client, handoff.id, promptTab], + [client, task.id, promptTab], ); const sendMessage = useCallback(() => { @@ -234,12 +235,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetActiveTabId(promptTab.id); onSetLastAgentTabId(promptTab.id); void client.sendMessage({ - handoffId: handoff.id, + taskId: task.id, tabId: promptTab.id, text, attachments, }); - }, [attachments, client, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]); + }, [attachments, client, draft, task.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]); const stopAgent = useCallback(() => { if (!promptTab) { @@ -247,10 +248,10 @@ const TranscriptPanel = memo(function TranscriptPanel({ } void client.stopAgent({ - handoffId: handoff.id, + taskId: task.id, tabId: promptTab.id, }); - }, [client, handoff.id, promptTab]); + }, [client, task.id, promptTab]); const switchTab = useCallback( (tabId: string) => { @@ -258,30 +259,30 @@ const TranscriptPanel = memo(function TranscriptPanel({ if (!isDiffTab(tabId)) { onSetLastAgentTabId(tabId); - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (tab?.unread) { void client.setSessionUnread({ - handoffId: handoff.id, + taskId: task.id, tabId, unread: false, }); } - onSyncRouteSession(handoff.id, tabId); + onSyncRouteSession(task.id, tabId); } }, - [client, handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], + [client, task.id, task.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], ); const setTabUnread = useCallback( (tabId: string, unread: boolean) => { - void client.setSessionUnread({ handoffId: handoff.id, tabId, unread }); + void client.setSessionUnread({ taskId: task.id, tabId, unread }); }, - [client, handoff.id], + [client, task.id], ); const startRenamingTab = useCallback( (tabId: string) => { - const targetTab = handoff.tabs.find((candidate) => candidate.id === tabId); + const targetTab = task.tabs.find((candidate) => candidate.id === tabId); if (!targetTab) { throw new Error(`Unable to rename missing session tab ${tabId}`); } @@ -289,7 +290,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ setEditingSessionTabId(tabId); setEditingSessionName(targetTab.sessionName); }, - [handoff.tabs], + [task.tabs], ); const cancelTabRename = useCallback(() => { @@ -309,16 +310,16 @@ const TranscriptPanel = memo(function TranscriptPanel({ } void client.renameSession({ - handoffId: handoff.id, + taskId: task.id, tabId: editingSessionTabId, title: trimmedName, }); cancelTabRename(); - }, [cancelTabRename, client, editingSessionName, editingSessionTabId, handoff.id]); + }, [cancelTabRename, client, editingSessionName, editingSessionTabId, task.id]); const closeTab = useCallback( (tabId: string) => { - const remainingTabs = handoff.tabs.filter((candidate) => candidate.id !== tabId); + const remainingTabs = task.tabs.filter((candidate) => candidate.id !== tabId); const nextTabId = remainingTabs[0]?.id ?? null; if (activeTabId === tabId) { @@ -328,10 +329,10 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetLastAgentTabId(nextTabId); } - onSyncRouteSession(handoff.id, nextTabId); - void client.closeTab({ handoffId: handoff.id, tabId }); + onSyncRouteSession(task.id, nextTabId); + void client.closeTab({ taskId: task.id, tabId }); }, - [activeTabId, client, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], + [activeTabId, client, task.id, task.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], ); const closeDiffTab = useCallback( @@ -340,35 +341,35 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetOpenDiffs(nextOpenDiffs); if (activeTabId === diffTabId(path)) { onSetActiveTabId( - nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(handoff)), + nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(task)), ); } }, - [activeTabId, handoff, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs], + [activeTabId, task, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs], ); const addTab = useCallback(() => { void (async () => { - const { tabId } = await client.addTab({ handoffId: handoff.id }); + const { tabId } = await client.addTab({ taskId: task.id }); onSetLastAgentTabId(tabId); onSetActiveTabId(tabId); - onSyncRouteSession(handoff.id, tabId); + onSyncRouteSession(task.id, tabId); })(); - }, [client, handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]); + }, [client, task.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]); const changeModel = useCallback( (model: ModelId) => { if (!promptTab) { - throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`); + throw new Error(`Unable to change model for task ${task.id} without an active prompt tab`); } void client.changeModel({ - handoffId: handoff.id, + taskId: task.id, tabId: promptTab.id, model, }); }, - [client, handoff.id, promptTab], + [client, task.id, promptTab], ); const addAttachment = useCallback( @@ -436,7 +437,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ return ( file.path === activeDiff)} - diff={handoff.diffs[activeDiff]} + file={task.fileChanges.find((file) => file.path === activeDiff)} + diff={task.diffs[activeDiff]} onAddAttachment={addAttachment} /> - ) : handoff.tabs.length === 0 ? ( + ) : task.tabs.length === 0 ? (

Create the first session

- Sessions are where you chat with the agent. Start one now to send the first prompt on this handoff. + Sessions are where you chat with the agent. Start one now to send the first prompt on this task.

+ ) : null} + {banner.dismissible ? ( + + ) : null} +
+ + ); +} + export function MockLayout({ client, workspaceId, - selectedHandoffId, + selectedTaskId, selectedSessionId, sidebarTitle, sidebarSubtitle, + organizationGithub, + onRetryGithubSync, + onReconnectGithub, sidebarActions, }: MockLayoutProps) { const navigate = useNavigate(); @@ -581,53 +731,54 @@ export function MockLayout({ client.getSnapshot.bind(client), client.getSnapshot.bind(client), ); - const handoffs = viewModel.handoffs ?? []; - const projects = viewModel.projects ?? []; - const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState>({}); - const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState>({}); - const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState>({}); + const tasks = viewModel.tasks ?? []; + const repos = viewModel.repos ?? []; + const repoSections = viewModel.repoSections ?? []; + const [activeTabIdByTask, setActiveTabIdByTask] = useState>({}); + const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState>({}); + const [openDiffsByTask, setOpenDiffsByTask] = useState>({}); - const activeHandoff = useMemo( - () => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, - [handoffs, selectedHandoffId], + const activeTask = useMemo( + () => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, + [tasks, selectedTaskId], ); useEffect(() => { - if (activeHandoff) { + if (activeTask) { return; } - const fallbackHandoffId = handoffs[0]?.id; - if (!fallbackHandoffId) { + const fallbackTaskId = tasks[0]?.id; + if (!fallbackTaskId) { return; } - const fallbackHandoff = handoffs.find((handoff) => handoff.id === fallbackHandoffId) ?? null; + const fallbackTask = tasks.find((task) => task.id === fallbackTaskId) ?? null; void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: fallbackHandoffId, + taskId: fallbackTaskId, }, - search: { sessionId: fallbackHandoff?.tabs[0]?.id ?? undefined }, + search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined }, replace: true, }); - }, [activeHandoff, handoffs, navigate, workspaceId]); + }, [activeTask, tasks, navigate, workspaceId]); - const openDiffs = activeHandoff ? sanitizeOpenDiffs(activeHandoff, openDiffsByHandoff[activeHandoff.id]) : []; - const lastAgentTabId = activeHandoff ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) : null; - const activeTabId = activeHandoff - ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) + const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : []; + const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null; + const activeTabId = activeTask + ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null; const syncRouteSession = useCallback( - (handoffId: string, sessionId: string | null, replace = false) => { + (taskId: string, sessionId: string | null, replace = false) => { void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId, + taskId, }, search: { sessionId: sessionId ?? undefined }, ...(replace ? { replace: true } : {}), @@ -637,115 +788,115 @@ export function MockLayout({ ); useEffect(() => { - if (!activeHandoff) { + if (!activeTask) { return; } - const resolvedRouteSessionId = sanitizeLastAgentTabId(activeHandoff, selectedSessionId); + const resolvedRouteSessionId = sanitizeLastAgentTabId(activeTask, selectedSessionId); if (!resolvedRouteSessionId) { return; } if (selectedSessionId !== resolvedRouteSessionId) { - syncRouteSession(activeHandoff.id, resolvedRouteSessionId, true); + syncRouteSession(activeTask.id, resolvedRouteSessionId, true); return; } - if (lastAgentTabIdByHandoff[activeHandoff.id] === resolvedRouteSessionId) { + if (lastAgentTabIdByTask[activeTask.id] === resolvedRouteSessionId) { return; } - setLastAgentTabIdByHandoff((current) => ({ + setLastAgentTabIdByTask((current) => ({ ...current, - [activeHandoff.id]: resolvedRouteSessionId, + [activeTask.id]: resolvedRouteSessionId, })); - setActiveTabIdByHandoff((current) => { - const currentActive = current[activeHandoff.id]; + setActiveTabIdByTask((current) => { + const currentActive = current[activeTask.id]; if (currentActive && isDiffTab(currentActive)) { return current; } return { ...current, - [activeHandoff.id]: resolvedRouteSessionId, + [activeTask.id]: resolvedRouteSessionId, }; }); - }, [activeHandoff, lastAgentTabIdByHandoff, selectedSessionId, syncRouteSession]); + }, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]); - const createHandoff = useCallback(() => { + const createTask = useCallback(() => { void (async () => { - const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? ""; + const repoId = activeTask?.repoId ?? viewModel.repos[0]?.id ?? ""; if (!repoId) { - throw new Error("Cannot create a handoff without an available repo"); + throw new Error("Cannot create a task without an available repo"); } - const { handoffId, tabId } = await client.createHandoff({ + const { taskId, tabId } = await client.createTask({ repoId, task: "", model: "gpt-4o", }); await navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId, + taskId, }, search: { sessionId: tabId ?? undefined }, }); })(); - }, [activeHandoff?.repoId, client, navigate, viewModel.repos, workspaceId]); + }, [activeTask?.repoId, client, navigate, viewModel.repos, workspaceId]); const openDiffTab = useCallback( (path: string) => { - if (!activeHandoff) { - throw new Error("Cannot open a diff tab without an active handoff"); + if (!activeTask) { + throw new Error("Cannot open a diff tab without an active task"); } - setOpenDiffsByHandoff((current) => { - const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]); + setOpenDiffsByTask((current) => { + const existing = sanitizeOpenDiffs(activeTask, current[activeTask.id]); if (existing.includes(path)) { return current; } return { ...current, - [activeHandoff.id]: [...existing, path], + [activeTask.id]: [...existing, path], }; }); - setActiveTabIdByHandoff((current) => ({ + setActiveTabIdByTask((current) => ({ ...current, - [activeHandoff.id]: diffTabId(path), + [activeTask.id]: diffTabId(path), })); }, - [activeHandoff], + [activeTask], ); - const selectHandoff = useCallback( + const selectTask = useCallback( (id: string) => { - const handoff = handoffs.find((candidate) => candidate.id === id) ?? null; + const task = tasks.find((candidate) => candidate.id === id) ?? null; void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: id, + taskId: id, }, - search: { sessionId: handoff?.tabs[0]?.id ?? undefined }, + search: { sessionId: task?.tabs[0]?.id ?? undefined }, }); }, - [handoffs, navigate, workspaceId], + [tasks, navigate, workspaceId], ); - const markHandoffUnread = useCallback((id: string) => { - void client.markHandoffUnread({ handoffId: id }); + const markTaskUnread = useCallback((id: string) => { + void client.markTaskUnread({ taskId: id }); }, [client]); - const renameHandoff = useCallback( + const renameTask = useCallback( (id: string) => { - const currentHandoff = handoffs.find((handoff) => handoff.id === id); - if (!currentHandoff) { - throw new Error(`Unable to rename missing handoff ${id}`); + const currentTask = tasks.find((task) => task.id === id); + if (!currentTask) { + throw new Error(`Unable to rename missing task ${id}`); } - const nextTitle = window.prompt("Rename handoff", currentHandoff.title); + const nextTitle = window.prompt("Rename task", currentTask.title); if (nextTitle === null) { return; } @@ -755,19 +906,19 @@ export function MockLayout({ return; } - void client.renameHandoff({ handoffId: id, value: trimmedTitle }); + void client.renameTask({ taskId: id, value: trimmedTitle }); }, - [client, handoffs], + [client, tasks], ); const renameBranch = useCallback( (id: string) => { - const currentHandoff = handoffs.find((handoff) => handoff.id === id); - if (!currentHandoff) { - throw new Error(`Unable to rename missing handoff ${id}`); + const currentTask = tasks.find((task) => task.id === id); + if (!currentTask) { + throw new Error(`Unable to rename missing task ${id}`); } - const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? ""); + const nextBranch = window.prompt("Rename branch", currentTask.branch ?? ""); if (nextBranch === null) { return; } @@ -777,197 +928,206 @@ export function MockLayout({ return; } - void client.renameBranch({ handoffId: id, value: trimmedBranch }); + void client.renameBranch({ taskId: id, value: trimmedBranch }); }, - [client, handoffs], + [client, tasks], ); - const archiveHandoff = useCallback(() => { - if (!activeHandoff) { - throw new Error("Cannot archive without an active handoff"); + const archiveTask = useCallback(() => { + if (!activeTask) { + throw new Error("Cannot archive without an active task"); } - void client.archiveHandoff({ handoffId: activeHandoff.id }); - }, [activeHandoff, client]); + void client.archiveTask({ taskId: activeTask.id }); + }, [activeTask, client]); const publishPr = useCallback(() => { - if (!activeHandoff) { - throw new Error("Cannot publish PR without an active handoff"); + if (!activeTask) { + throw new Error("Cannot publish PR without an active task"); } - void client.publishPr({ handoffId: activeHandoff.id }); - }, [activeHandoff, client]); + void client.publishPr({ taskId: activeTask.id }); + }, [activeTask, client]); - const pushHandoff = useCallback(() => { - if (!activeHandoff) { - throw new Error("Cannot push without an active handoff"); + const pushTask = useCallback(() => { + if (!activeTask) { + throw new Error("Cannot push without an active task"); } - void client.pushHandoff({ handoffId: activeHandoff.id }); - }, [activeHandoff, client]); + void client.pushTask({ taskId: activeTask.id }); + }, [activeTask, client]); const revertFile = useCallback( (path: string) => { - if (!activeHandoff) { - throw new Error("Cannot revert a file without an active handoff"); + if (!activeTask) { + throw new Error("Cannot revert a file without an active task"); } - setOpenDiffsByHandoff((current) => ({ + setOpenDiffsByTask((current) => ({ ...current, - [activeHandoff.id]: sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]).filter((candidate) => candidate !== path), + [activeTask.id]: sanitizeOpenDiffs(activeTask, current[activeTask.id]).filter((candidate) => candidate !== path), })); - setActiveTabIdByHandoff((current) => ({ + setActiveTabIdByTask((current) => ({ ...current, - [activeHandoff.id]: - current[activeHandoff.id] === diffTabId(path) - ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) - : current[activeHandoff.id] ?? null, + [activeTask.id]: + current[activeTask.id] === diffTabId(path) + ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) + : current[activeTask.id] ?? null, })); void client.revertFile({ - handoffId: activeHandoff.id, + taskId: activeTask.id, path, }); }, - [activeHandoff, client, lastAgentTabIdByHandoff], + [activeTask, client, lastAgentTabIdByTask], ); // Show full-page skeleton while the client snapshot is still empty (initial load) - const isInitialLoad = handoffs.length === 0 && projects.length === 0 && viewModel.repos.length === 0; + const isInitialLoad = tasks.length === 0 && repos.length === 0 && viewModel.repos.length === 0; if (isInitialLoad) { return ( - - - -
- - - - - - - -
- - - - - -
- - - - +
+ + + + +
+ + + + + + + +
+ + + + + +
+ + + + +
); } - if (!activeHandoff) { + if (!activeTask) { return ( - - - - -
+
+ + + + +
-

Create your first handoff

-

- {viewModel.repos.length > 0 - ? "Start from the sidebar to create a handoff on the first available repo." - : "No repos are available in this workspace yet."} -

- +

Create your first task

+

+ {viewModel.repos.length > 0 + ? "Start from the sidebar to create a task on the first available repo." + : "No repos are available in this workspace yet."} +

+ +
-
-
-
- -
+ +
+ + +
); } return ( - - - { - setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetLastAgentTabId={(tabId) => { - setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetOpenDiffs={(paths) => { - setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); - }} - /> - - +
+ + + + { + setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId })); + }} + onSetLastAgentTabId={(tabId) => { + setLastAgentTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId })); + }} + onSetOpenDiffs={(paths) => { + setOpenDiffsByTask((current) => ({ ...current, [activeTask.id]: paths })); + }} + /> + + +
); } diff --git a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx b/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx index 83c8904..26c0a86 100644 --- a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx +++ b/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx @@ -49,7 +49,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ >
- Handoff Events + Task Events {events.length}
diff --git a/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx index d8927f1..90e8689 100644 --- a/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -13,7 +13,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; -import { type FileTreeNode, type Handoff, diffTabId } from "./view-model"; +import { type FileTreeNode, type Task, diffTabId } from "./view-model"; const StatusCard = memo(function StatusCard({ label, @@ -145,7 +145,7 @@ const FileTree = memo(function FileTree({ }); export const RightSidebar = memo(function RightSidebar({ - handoff, + task, activeTabId, onOpenDiff, onArchive, @@ -153,7 +153,7 @@ export const RightSidebar = memo(function RightSidebar({ onRevertFile, onPublishPr, }: { - handoff: Handoff; + task: Task; activeTabId: string | null; onOpenDiff: (path: string) => void; onArchive: () => void; @@ -164,14 +164,14 @@ export const RightSidebar = memo(function RightSidebar({ const [css, theme] = useStyletron(); const [rightTab, setRightTab] = useState<"changes" | "files">("changes"); const contextMenu = useContextMenu(); - const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]); - const isTerminal = handoff.status === "archived"; - const canPush = !isTerminal && Boolean(handoff.branch); - const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null; + const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); + const isTerminal = task.status === "archived"; + const canPush = !isTerminal && Boolean(task.branch); + const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null; const pullRequestStatus = - handoff.pullRequest == null + task.pullRequest == null ? "Not published" - : `#${handoff.pullRequest.number} ${handoff.pullRequest.status === "draft" ? "Draft" : "Ready"}`; + : `#${task.pullRequest.number} ${task.pullRequest.status === "draft" ? "Draft" : "Ready"}`; const copyFilePath = useCallback(async (path: string) => { try { @@ -309,7 +309,7 @@ export const RightSidebar = memo(function RightSidebar({ })} > Changes - {handoff.fileChanges.length > 0 ? ( + {task.fileChanges.length > 0 ? ( - {handoff.fileChanges.length} + {task.fileChanges.length} ) : null} @@ -356,17 +356,17 @@ export const RightSidebar = memo(function RightSidebar({
- +
{rightTab === "changes" ? (
- {handoff.fileChanges.length === 0 ? ( + {task.fileChanges.length === 0 ? (
No changes yet
) : null} - {handoff.fileChanges.map((file) => { + {task.fileChanges.map((file) => { const isActive = activeTabId === diffTabId(file.path); const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode; const iconColor = file.type === "A" ? "#7ee787" : file.type === "D" ? "#ffa198" : theme.colors.contentTertiary; @@ -421,9 +421,9 @@ export const RightSidebar = memo(function RightSidebar({
) : (
- {handoff.fileTree.length > 0 ? ( + {task.fileTree.length > 0 ? ( void; onCreate: () => void; onMarkUnread: (id: string) => void; - onRenameHandoff: (id: string) => void; + onRenameTask: (id: string) => void; onRenameBranch: (id: string) => void; }) { const [css, theme] = useStyletron(); const contextMenu = useContextMenu(); - const [expandedProjects, setExpandedProjects] = useState>({}); + const [expandedRepos, setExpandedRepos] = useState>({}); return ( @@ -61,7 +61,7 @@ export const Sidebar = memo(function Sidebar({
- {mockAccount.label} + + {isMockFrontendClient ? mockAccount.label : "Live GitHub identity"} +
- Sign-in always lands as this single mock user. Organization choice happens on the next screen. + {isMockFrontendClient + ? "Sign-in always lands as this single mock user. Organization choice happens on the next screen." + : "In remote mode this card is replaced by the live GitHub user once the OAuth callback completes."}
@@ -492,9 +491,7 @@ export function MockOrganizationSelectorPage() { onClick={() => { void (async () => { await client.selectOrganization(organization.id); - await navigate({ - to: organization.repoImportStatus === "ready" ? workspacePath(organization) : importPath(organization), - }); + await navigate({ to: workspacePath(organization) }); })(); }} style={primaryButtonStyle()} @@ -515,110 +512,6 @@ export function MockOrganizationSelectorPage() { ); } -export function MockOrganizationImportPage({ organization }: { organization: FactoryOrganization }) { - const client = useMockAppClient(); - const snapshot = useMockAppSnapshot(); - const user = activeMockUser(snapshot); - const navigate = useNavigate(); - - useEffect(() => { - if (organization.repoImportStatus === "ready") { - void navigate({ to: workspacePath(organization), replace: true }); - } - }, [navigate, organization]); - - return ( - void navigate({ to: "/organizations" })} style={secondaryButtonStyle()}> - - Back - - } - onSignOut={() => { - void (async () => { - await client.signOut(); - await navigate({ to: "/signin" }); - })(); - }} - > -
-
-
- -
-
- {organization.repoImportStatus === "ready" ? "Import complete" : "Preparing repository catalog"} -
-
{organization.github.lastSyncLabel}
-
-
-
- The mock client now simulates the expected onboarding pause: GitHub app access is validated, repository metadata - is imported, and the resulting workspace stays blocked until ready. -
-
-
-
-
- {organization.github.installationStatus !== "connected" ? ( - - ) : null} - - -
-
-
- - - -
-
- - ); -} - export function MockOrganizationSettingsPage({ organization }: { organization: FactoryOrganization }) { const client = useMockAppClient(); const snapshot = useMockAppSnapshot(); @@ -631,6 +524,12 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F () => `${organization.seatAssignments.length} of ${organization.billing.seatsIncluded} seats already accrued`, [organization.billing.seatsIncluded, organization.seatAssignments.length], ); + const openWorkspace = () => { + void (async () => { + await client.selectOrganization(organization.id); + await navigate({ to: workspacePath(organization) }); + })(); + }; useEffect(() => { setDisplayName(organization.settings.displayName); @@ -648,7 +547,7 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F user={user} title={`${organization.settings.displayName} settings`} eyebrow="Organization" - description="This mock settings surface covers the org profile, GitHub installation state, repository import controls, and the seat-accrual rule from the spec. It is intentionally product-shaped even though the real backend is not wired yet." + description="This mock settings surface covers the org profile, GitHub installation state, background repository sync controls, and the seat-accrual rule from the spec. It is intentionally product-shaped even though the real backend is not wired yet." actions={ <> - @@ -719,8 +618,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F > Save settings -
@@ -741,8 +640,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F - @@ -784,19 +683,32 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa const snapshot = useMockAppSnapshot(); const user = activeMockUser(snapshot); const navigate = useNavigate(); + const hasStripeCustomer = organization.billing.stripeCustomerId.trim().length > 0; + const effectivePlanId: FactoryBillingPlanId = hasStripeCustomer ? organization.billing.planId : "free"; + const effectiveSeatsIncluded = hasStripeCustomer ? organization.billing.seatsIncluded : 1; + const openWorkspace = () => { + void (async () => { + await client.selectOrganization(organization.id); + await navigate({ to: workspacePath(organization) }); + })(); + }; return ( - @@ -811,12 +723,12 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
{(Object.entries(planCatalog) as Array<[FactoryBillingPlanId, (typeof planCatalog)[FactoryBillingPlanId]]>).map(([planId, plan]) => { - const isCurrent = organization.billing.planId === planId; + const isCurrent = effectivePlanId === planId; return (
@@ -863,25 +775,41 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
Subscription controls
- Stripe customer {organization.billing.stripeCustomerId}. This mock screen intentionally mirrors a hosted - billing portal entry point and the in-product summary beside it. + Stripe customer {organization.billing.stripeCustomerId || "pending"}.{" "} + {isMockFrontendClient + ? "This mock screen intentionally mirrors a hosted billing portal entry point and the in-product summary beside it." + : hasStripeCustomer + ? "Use the portal for payment method management and invoices, while in-product controls keep renewal state visible in the app shell." + : "Complete checkout first, then use the portal and renewal controls once Stripe has created the customer and subscription."}
- {organization.billing.status === "scheduled_cancel" ? ( - + {hasStripeCustomer ? ( + organization.billing.status === "scheduled_cancel" ? ( + + ) : ( + + ) ) : ( - )}
@@ -951,7 +879,11 @@ export function MockHostedCheckoutPage({ user={user} title={`Checkout ${plan.label}`} eyebrow="Hosted Checkout" - description="This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen." + description={ + isMockFrontendClient + ? "This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen." + : "This hands off to a live Stripe Checkout session. After payment succeeds, the backend finalizes the session and routes back into the billing screen." + } actions={
@@ -995,12 +927,14 @@ export function MockHostedCheckoutPage({ onClick={() => { void (async () => { await client.completeHostedCheckout(organization.id, planId); - await navigate({ to: billingPath(organization), replace: true }); + if (isMockFrontendClient) { + await navigate({ to: billingPath(organization), replace: true }); + } })(); }} style={primaryButtonStyle()} > - Complete checkout + {isMockFrontendClient ? "Complete checkout" : "Continue to Stripe"}
diff --git a/factory/packages/frontend/src/components/workspace-dashboard.tsx b/factory/packages/frontend/src/components/workspace-dashboard.tsx index 1b9033c..4c722b2 100644 --- a/factory/packages/frontend/src/components/workspace-dashboard.tsx +++ b/factory/packages/frontend/src/components/workspace-dashboard.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared"; +import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared"; import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client"; -import { groupHandoffStatus } from "@sandbox-agent/factory-client/view-model"; +import { groupTaskStatus } from "@sandbox-agent/factory-client/view-model"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Button } from "baseui/button"; @@ -15,13 +15,13 @@ import { StyledDivider } from "baseui/divider"; import { styled, useStyletron } from "baseui"; import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography"; import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal, Shuffle } from "lucide-react"; -import { formatDiffStat } from "../features/handoffs/model"; +import { formatDiffStat } from "../features/tasks/model"; import { buildTranscript, resolveSessionSelection } from "../features/sessions/model"; import { backendClient } from "../lib/backend"; interface WorkspaceDashboardProps { workspaceId: string; - selectedHandoffId?: string; + selectedTaskId?: string; selectedRepoId?: string; } @@ -87,7 +87,7 @@ const DetailRail = styled("aside", ({ $theme }) => ({ const FILTER_OPTIONS: SelectItem[] = [ { id: "active", label: "Active + Unmapped" }, - { id: "archived", label: "Archived Handoffs" }, + { id: "archived", label: "Archived Tasks" }, { id: "unmapped", label: "Unmapped Only" }, { id: "all", label: "All Branches" }, ]; @@ -97,8 +97,8 @@ const AGENT_OPTIONS: SelectItem[] = [ { id: "claude", label: "claude" }, ]; -function statusKind(status: HandoffSummary["status"]): StatusTagKind { - const group = groupHandoffStatus(status); +function statusKind(status: TaskSummary["status"]): StatusTagKind { + const group = groupTaskStatus(status); if (group === "running") return "positive"; if (group === "queued") return "warning"; if (group === "error") return "negative"; @@ -137,21 +137,21 @@ function branchTestIdToken(value: string): string { } function useSessionEvents( - handoff: HandoffRecord | null, + task: TaskRecord | null, sessionId: string | null ): ReturnType> { return useQuery({ - queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""], - enabled: Boolean(handoff?.activeSandboxId && sessionId), + queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""], + enabled: Boolean(task?.activeSandboxId && sessionId), refetchInterval: 2_500, queryFn: async () => { - if (!handoff?.activeSandboxId || !sessionId) { + if (!task?.activeSandboxId || !sessionId) { return { items: [] }; } return backendClient.listSandboxSessionEvents( - handoff.workspaceId, - handoff.providerId, - handoff.activeSandboxId, + task.workspaceId, + task.providerId, + task.activeSandboxId, { sessionId, limit: 120, @@ -186,7 +186,7 @@ function repoSummary(overview: RepoOverview | undefined): { let openPrs = 0; for (const row of overview.branches) { - if (row.handoffId) { + if (row.taskId) { mapped += 1; } if (row.conflictsWithMain) { @@ -225,13 +225,13 @@ function branchKind(row: RepoBranchRecord): StatusTagKind { function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean { if (filter === "archived") { - return branch.handoffStatus === "archived"; + return branch.taskStatus === "archived"; } if (filter === "unmapped") { - return branch.handoffId === null; + return branch.taskId === null; } if (filter === "active") { - return branch.handoffStatus !== "archived"; + return branch.taskStatus !== "archived"; } return true; } @@ -364,7 +364,7 @@ function MetaRow({ label, value, mono = false }: { label: string; value: string; ); } -export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRepoId }: WorkspaceDashboardProps) { +export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId }: WorkspaceDashboardProps) { const [css, theme] = useStyletron(); const navigate = useNavigate(); const repoOverviewMode = typeof selectedRepoId === "string" && selectedRepoId.length > 0; @@ -377,7 +377,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const [newBranchName, setNewBranchName] = useState(""); const [createOnBranch, setCreateOnBranch] = useState(null); const [addRepoOpen, setAddRepoOpen] = useState(false); - const [createHandoffOpen, setCreateHandoffOpen] = useState(false); + const [createTaskOpen, setCreateTaskOpen] = useState(false); const [addRepoRemote, setAddRepoRemote] = useState(""); const [addRepoError, setAddRepoError] = useState(null); const [stackActionError, setStackActionError] = useState(null); @@ -396,21 +396,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }); const [createError, setCreateError] = useState(null); - const handoffsQuery = useQuery({ - queryKey: ["workspace", workspaceId, "handoffs"], - queryFn: async () => backendClient.listHandoffs(workspaceId), + const tasksQuery = useQuery({ + queryKey: ["workspace", workspaceId, "tasks"], + queryFn: async () => backendClient.listTasks(workspaceId), refetchInterval: 2_500, }); - const handoffDetailQuery = useQuery({ - queryKey: ["workspace", workspaceId, "handoff-detail", selectedHandoffId], - enabled: Boolean(selectedHandoffId && !repoOverviewMode), + const taskDetailQuery = useQuery({ + queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId], + enabled: Boolean(selectedTaskId && !repoOverviewMode), refetchInterval: 2_500, queryFn: async () => { - if (!selectedHandoffId) { - throw new Error("No handoff"); + if (!selectedTaskId) { + throw new Error("No task"); } - return backendClient.getHandoff(workspaceId, selectedHandoffId); + return backendClient.getTask(workspaceId, selectedTaskId); }, }); @@ -453,9 +453,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep } }, [newAgentType]); - const rows = handoffsQuery.data ?? []; + const rows = tasksQuery.data ?? []; const repoGroups = useMemo(() => { - const byRepo = new Map(); + const byRepo = new Map(); for (const row of rows) { const bucket = byRepo.get(row.repoId); if (bucket) { @@ -467,13 +467,13 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep return repos .map((repo) => { - const handoffs = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt); - const latestHandoffAt = handoffs[0]?.updatedAt ?? 0; + const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt); + const latestTaskAt = tasks[0]?.updatedAt ?? 0; return { repoId: repo.repoId, repoRemote: repo.remoteUrl, - latestActivityAt: Math.max(repo.updatedAt, latestHandoffAt), - handoffs, + latestActivityAt: Math.max(repo.updatedAt, latestTaskAt), + tasks, }; }) .sort((a, b) => { @@ -485,11 +485,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }, [repos, rows]); const selectedSummary = useMemo( - () => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null, - [rows, selectedHandoffId] + () => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null, + [rows, selectedTaskId] ); - const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null); + const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null); const activeSandbox = useMemo(() => { if (!selectedForSession) return null; @@ -500,23 +500,23 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }, [selectedForSession]); useEffect(() => { - if (!repoOverviewMode && !selectedHandoffId && rows.length > 0) { + if (!repoOverviewMode && !selectedTaskId && rows.length > 0) { void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: rows[0]!.handoffId, + taskId: rows[0]!.taskId, }, search: { sessionId: undefined }, replace: true, }); } - }, [navigate, repoOverviewMode, rows, selectedHandoffId, workspaceId]); + }, [navigate, repoOverviewMode, rows, selectedTaskId, workspaceId]); useEffect(() => { setActiveSessionId(null); setDraft(""); - }, [selectedForSession?.handoffId]); + }, [selectedForSession?.taskId]); const sessionsQuery = useQuery({ queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"], @@ -537,7 +537,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep () => resolveSessionSelection({ explicitSessionId: activeSessionId, - handoffSessionId: selectedForSession?.activeSessionId ?? null, + taskSessionId: selectedForSession?.activeSessionId ?? null, sessions: sessionRows, }), [activeSessionId, selectedForSession?.activeSessionId, sessionRows] @@ -547,9 +547,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId); const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId); - const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { + const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { if (!selectedForSession || !activeSandbox?.sandboxId) { - throw new Error("No sandbox is available for this handoff"); + throw new Error("No sandbox is available for this task"); } return backendClient.createSandboxSession({ workspaceId, @@ -562,7 +562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }; const createSession = useMutation({ - mutationFn: async () => startSessionFromHandoff(), + mutationFn: async () => startSessionFromTask(), onSuccess: async (session) => { setActiveSessionId(session.id); await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]); @@ -573,7 +573,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep if (resolvedSessionId) { return resolvedSessionId; } - const created = await startSessionFromHandoff(); + const created = await startSessionFromTask(); setActiveSessionId(created.id); await sessionsQuery.refetch(); return created.id; @@ -582,7 +582,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const sendPrompt = useMutation({ mutationFn: async (prompt: string) => { if (!selectedForSession || !activeSandbox?.sandboxId) { - throw new Error("No sandbox is available for this handoff"); + throw new Error("No sandbox is available for this task"); } const sessionId = await ensureSessionForPrompt(); await backendClient.sendSandboxPrompt({ @@ -600,9 +600,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }); const transcript = buildTranscript(eventsQuery.data?.items ?? []); - const canCreateHandoff = createRepoId.trim().length > 0 && newTask.trim().length > 0; + const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0; - const createHandoff = useMutation({ + const createTask = useMutation({ mutationFn: async () => { const repoId = createRepoId.trim(); const task = newTask.trim(); @@ -613,7 +613,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const draftTitle = newTitle.trim(); const draftBranchName = newBranchName.trim(); - return backendClient.createHandoff({ + return backendClient.createTask({ workspaceId, repoId, task, @@ -623,20 +623,20 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep onBranch: createOnBranch ?? undefined, }); }, - onSuccess: async (handoff) => { + onSuccess: async (task) => { setCreateError(null); setNewTask(""); setNewTitle(""); setNewBranchName(""); setCreateOnBranch(null); - setCreateHandoffOpen(false); - await handoffsQuery.refetch(); + setCreateTaskOpen(false); + await tasksQuery.refetch(); await repoOverviewQuery.refetch(); await navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: handoff.handoffId, + taskId: task.taskId, }, search: { sessionId: undefined }, }); @@ -696,7 +696,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setStackActionMessage(null); setStackActionError(result.message); } - await Promise.all([repoOverviewQuery.refetch(), handoffsQuery.refetch()]); + await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]); }, onError: (error) => { setStackActionMessage(null); @@ -712,7 +712,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep if (!newTask.trim()) { setNewTask(`Continue work on ${branchName}`); } - setCreateHandoffOpen(true); + setCreateTaskOpen(true); }; const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]); @@ -858,19 +858,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep borderTop: `1px solid ${theme.colors.borderOpaque}`, })} > - Handoffs + Tasks - {handoffsQuery.isLoading ? ( + {tasksQuery.isLoading ? ( <> ) : null} - {!handoffsQuery.isLoading && repoGroups.length === 0 ? ( - No repos or handoffs yet. Add a repo to start a workspace. + {!tasksQuery.isLoading && repoGroups.length === 0 ? ( + No repos or tasks yet. Add a repo to start a workspace. ) : null} {repoGroups.map((group) => ( @@ -912,15 +912,15 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep gap: "0", })} > - {group.handoffs - .filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId) - .map((handoff) => { - const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId; + {group.tasks + .filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId) + .map((task) => { + const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId; return ( - {handoff.title ?? "Determining title..."} + {task.title ?? "Determining title..."}
- {handoff.branchName ?? "Determining branch..."} + {task.branchName ?? "Determining branch..."} - {handoff.status} + {task.status}
); @@ -982,11 +982,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setCreateRepoId(group.repoId); setCreateOnBranch(null); setCreateError(null); - setCreateHandoffOpen(true); + setCreateTaskOpen(true); }} - data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`} + data-testid={group.repoId === createRepoId ? "task-create-open" : `task-create-open-${group.repoId}`} > - Create Handoff + Create Task @@ -1198,8 +1198,8 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep {formatRelativeAge(branch.updatedAt)} - - {branch.handoffId ? "handoff" : "unmapped"} + + {branch.taskId ? "task" : "unmapped"} {branch.trackedInStack ? stack : null} @@ -1282,7 +1282,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep Reparent - {!branch.handoffId ? ( + {!branch.taskId ? ( ) : null} @@ -1332,7 +1332,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep > - {selectedForSession ? selectedForSession.title ?? "Determining title..." : "No handoff selected"} + {selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"} {selectedForSession ? ( {selectedForSession.status} @@ -1365,7 +1365,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep })} > {!selectedForSession ? ( - Select a handoff from the left sidebar. + Select a task from the left sidebar. ) : ( <>
) : null} @@ -1436,17 +1436,17 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep {transcript.length === 0 && !eventsQuery.isLoading ? ( - {groupHandoffStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage + {groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage ? `Session failed: ${selectedForSession.statusMessage}` : !activeSandbox?.sandboxId ? selectedForSession.statusMessage ? `Sandbox unavailable: ${selectedForSession.statusMessage}` - : "This handoff is still provisioning its sandbox." + : "This task is still provisioning its sandbox." : staleSessionId ? `Session ${staleSessionId} is unavailable. Start a new session to continue.` : resolvedSessionId ? "No transcript events yet. Send a prompt to start this session." - : "No active session for this handoff."} + : "No active session for this task."} ) : null} @@ -1519,7 +1519,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep placeholder="Send a follow-up prompt to this session" rows={5} disabled={!activeSandbox?.sandboxId} - overrides={textareaTestIdOverrides("handoff-session-prompt")} + overrides={textareaTestIdOverrides("task-session-prompt")} />
- {repoOverviewMode ? "Repo Details" : "Handoff Details"} + {repoOverviewMode ? "Repo Details" : "Task Details"} @@ -1619,8 +1619,8 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
)} @@ -1629,7 +1629,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep ) ) : !selectedForSession ? ( - No handoff selected. + No task selected. ) : ( <> @@ -1645,7 +1645,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep gap: theme.sizing.scale300, })} > - + @@ -1689,7 +1689,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep - {groupHandoffStatus(selectedForSession.status) === "error" ? ( + {groupTaskStatus(selectedForSession.status) === "error" ? (
{ - setCreateHandoffOpen(false); + setCreateTaskOpen(false); setCreateOnBranch(null); }} overrides={modalOverrides} > - Create Handoff + Create Task
- Pick a repo, describe the task, and the backend will create a handoff. + Pick a repo, describe the task, and the backend will create a task.
@@ -1803,7 +1803,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setCreateRepoId(next); } }} - overrides={selectTestIdOverrides("handoff-create-repo")} + overrides={selectTestIdOverrides("task-create-repo")} /> {repos.length === 0 ? (
{ - setCreateHandoffOpen(false); + setCreateTaskOpen(false); setAddRepoError(null); setAddRepoOpen(true); }} @@ -1852,7 +1852,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setNewAgentType(next); } }} - overrides={selectTestIdOverrides("handoff-create-agent")} + overrides={selectTestIdOverrides("task-create-agent")} />
@@ -1865,7 +1865,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep onChange={(event) => setNewTask(event.target.value)} placeholder="Task" rows={6} - overrides={textareaTestIdOverrides("handoff-create-task")} + overrides={textareaTestIdOverrides("task-create-task")} />
@@ -1877,7 +1877,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep placeholder="Title (optional)" value={newTitle} onChange={(event) => setNewTitle(event.target.value)} - overrides={inputTestIdOverrides("handoff-create-title")} + overrides={inputTestIdOverrides("task-create-title")} />
@@ -1886,19 +1886,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep Branch {createOnBranch ? ( - + ) : ( setNewBranchName(event.target.value)} - overrides={inputTestIdOverrides("handoff-create-branch")} + overrides={inputTestIdOverrides("task-create-branch")} /> )}
{createError ? ( - + {createError} ) : null} @@ -1908,21 +1908,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep diff --git a/factory/packages/frontend/src/factory-client-view-model.d.ts b/factory/packages/frontend/src/factory-client-view-model.d.ts index 1b8e3dc..e09c64b 100644 --- a/factory/packages/frontend/src/factory-client-view-model.d.ts +++ b/factory/packages/frontend/src/factory-client-view-model.d.ts @@ -1,7 +1,7 @@ declare module "@sandbox-agent/factory-client/view-model" { export { HANDOFF_STATUS_GROUPS, - groupHandoffStatus, + groupTaskStatus, } from "@sandbox-agent/factory-client"; - export type { HandoffStatusGroup } from "@sandbox-agent/factory-client"; + export type { TaskStatusGroup } from "@sandbox-agent/factory-client"; } diff --git a/factory/packages/frontend/src/features/handoffs/model.ts b/factory/packages/frontend/src/features/handoffs/model.ts deleted file mode 100644 index 4106078..0000000 --- a/factory/packages/frontend/src/features/handoffs/model.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { HandoffRecord } from "@sandbox-agent/factory-shared"; - -export interface RepoGroup { - repoId: string; - repoRemote: string; - handoffs: HandoffRecord[]; -} - -export function groupHandoffsByRepo(handoffs: HandoffRecord[]): RepoGroup[] { - const groups = new Map(); - - for (const handoff of handoffs) { - const group = groups.get(handoff.repoId); - if (group) { - group.handoffs.push(handoff); - continue; - } - - groups.set(handoff.repoId, { - repoId: handoff.repoId, - repoRemote: handoff.repoRemote, - handoffs: [handoff], - }); - } - - return Array.from(groups.values()) - .map((group) => ({ - ...group, - handoffs: [...group.handoffs].sort((a, b) => b.updatedAt - a.updatedAt), - })) - .sort((a, b) => { - const aLatest = a.handoffs[0]?.updatedAt ?? 0; - const bLatest = b.handoffs[0]?.updatedAt ?? 0; - if (aLatest !== bLatest) { - return bLatest - aLatest; - } - return a.repoRemote.localeCompare(b.repoRemote); - }); -} - -export function formatDiffStat(diffStat: string | null | undefined): string { - const normalized = diffStat?.trim(); - if (!normalized) { - return "-"; - } - if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") { - return "No changes"; - } - return normalized; -} diff --git a/factory/packages/frontend/src/features/sessions/model.test.ts b/factory/packages/frontend/src/features/sessions/model.test.ts index c2cc8ee..e685826 100644 --- a/factory/packages/frontend/src/features/sessions/model.test.ts +++ b/factory/packages/frontend/src/features/sessions/model.test.ts @@ -92,7 +92,7 @@ describe("resolveSessionSelection", () => { it("prefers explicit selection when present in session list", () => { const resolved = resolveSessionSelection({ explicitSessionId: "session-2", - handoffSessionId: "session-1", + taskSessionId: "session-1", sessions: [session("session-1"), session("session-2")] }); @@ -102,10 +102,10 @@ describe("resolveSessionSelection", () => { }); }); - it("falls back to handoff session when explicit selection is missing", () => { + it("falls back to task session when explicit selection is missing", () => { const resolved = resolveSessionSelection({ explicitSessionId: null, - handoffSessionId: "session-1", + taskSessionId: "session-1", sessions: [session("session-1")] }); @@ -118,7 +118,7 @@ describe("resolveSessionSelection", () => { it("falls back to the newest available session when configured session IDs are stale", () => { const resolved = resolveSessionSelection({ explicitSessionId: null, - handoffSessionId: "session-stale", + taskSessionId: "session-stale", sessions: [session("session-fresh")] }); @@ -131,7 +131,7 @@ describe("resolveSessionSelection", () => { it("marks stale session when no sessions are available", () => { const resolved = resolveSessionSelection({ explicitSessionId: null, - handoffSessionId: "session-stale", + taskSessionId: "session-stale", sessions: [] }); diff --git a/factory/packages/frontend/src/features/sessions/model.ts b/factory/packages/frontend/src/features/sessions/model.ts index 9085a13..01f3d4d 100644 --- a/factory/packages/frontend/src/features/sessions/model.ts +++ b/factory/packages/frontend/src/features/sessions/model.ts @@ -107,7 +107,7 @@ export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{ export function resolveSessionSelection(input: { explicitSessionId: string | null; - handoffSessionId: string | null; + taskSessionId: string | null; sessions: SandboxSessionRecord[]; }): { sessionId: string | null; @@ -120,8 +120,8 @@ export function resolveSessionSelection(input: { return { sessionId: input.explicitSessionId, staleSessionId: null }; } - if (hasSession(input.handoffSessionId)) { - return { sessionId: input.handoffSessionId, staleSessionId: null }; + if (hasSession(input.taskSessionId)) { + return { sessionId: input.taskSessionId, staleSessionId: null }; } const fallbackSessionId = input.sessions[0]?.id ?? null; @@ -133,8 +133,8 @@ export function resolveSessionSelection(input: { return { sessionId: null, staleSessionId: input.explicitSessionId }; } - if (input.handoffSessionId) { - return { sessionId: null, staleSessionId: input.handoffSessionId }; + if (input.taskSessionId) { + return { sessionId: null, staleSessionId: input.taskSessionId }; } return { sessionId: null, staleSessionId: null }; diff --git a/factory/packages/frontend/src/features/handoffs/model.test.ts b/factory/packages/frontend/src/features/tasks/model.test.ts similarity index 58% rename from factory/packages/frontend/src/features/handoffs/model.test.ts rename to factory/packages/frontend/src/features/tasks/model.test.ts index 3d82467..dc53418 100644 --- a/factory/packages/frontend/src/features/handoffs/model.test.ts +++ b/factory/packages/frontend/src/features/tasks/model.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@sandbox-agent/factory-shared"; -import { formatDiffStat, groupHandoffsByRepo } from "./model"; +import type { TaskRecord } from "@sandbox-agent/factory-shared"; +import { formatDiffStat, groupTasksByRepo } from "./model"; -const base: HandoffRecord = { +const base: TaskRecord = { workspaceId: "default", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", + taskId: "task-1", branchName: "feature/one", title: "Feature one", task: "Ship one", @@ -41,27 +41,27 @@ const base: HandoffRecord = { updatedAt: 10, }; -describe("groupHandoffsByRepo", () => { +describe("groupTasksByRepo", () => { it("groups by repo and sorts by recency", () => { - const rows: HandoffRecord[] = [ - { ...base, handoffId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 }, - { ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 }, - { ...base, handoffId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 }, + const rows: TaskRecord[] = [ + { ...base, taskId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 }, + { ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 }, + { ...base, taskId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 }, ]; - const groups = groupHandoffsByRepo(rows); + const groups = groupTasksByRepo(rows); expect(groups).toHaveLength(2); expect(groups[0]?.repoId).toBe("repo-a"); - expect(groups[0]?.handoffs[0]?.handoffId).toBe("h2"); + expect(groups[0]?.tasks[0]?.taskId).toBe("h2"); }); - it("sorts repo groups by latest handoff activity first", () => { - const rows: HandoffRecord[] = [ - { ...base, handoffId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 }, - { ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 }, + it("sorts repo groups by latest task activity first", () => { + const rows: TaskRecord[] = [ + { ...base, taskId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 }, + { ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 }, ]; - const groups = groupHandoffsByRepo(rows); + const groups = groupTasksByRepo(rows); expect(groups[0]?.repoId).toBe("repo-z"); expect(groups[1]?.repoId).toBe("repo-a"); }); diff --git a/factory/packages/frontend/src/features/tasks/model.ts b/factory/packages/frontend/src/features/tasks/model.ts new file mode 100644 index 0000000..fbebea2 --- /dev/null +++ b/factory/packages/frontend/src/features/tasks/model.ts @@ -0,0 +1,54 @@ +import type { TaskRecord } from "@sandbox-agent/factory-shared"; + +export interface RepoGroup { + repoId: string; + repoRemote: string; + tasks: TaskRecord[]; +} + +export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] { + const groups = new Map(); + + for (const task of tasks) { + const linkedRepoIds = task.repoIds?.length ? task.repoIds : [task.repoId]; + for (const repoId of linkedRepoIds) { + const group = groups.get(repoId); + if (group) { + group.tasks.push(task); + group.tasks = group.tasks; + continue; + } + + groups.set(repoId, { + repoId, + repoRemote: task.repoRemote, + tasks: [task], + }); + } + } + + return Array.from(groups.values()) + .map((group) => ({ + ...group, + tasks: [...group.tasks].sort((a, b) => b.updatedAt - a.updatedAt), + })) + .sort((a, b) => { + const aLatest = a.tasks[0]?.updatedAt ?? 0; + const bLatest = b.tasks[0]?.updatedAt ?? 0; + if (aLatest !== bLatest) { + return bLatest - aLatest; + } + return a.repoRemote.localeCompare(b.repoRemote); + }); +} + +export function formatDiffStat(diffStat: string | null | undefined): string { + const normalized = diffStat?.trim(); + if (!normalized) { + return "-"; + } + if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") { + return "No changes"; + } + return normalized; +} diff --git a/factory/packages/frontend/src/lib/mock-app.ts b/factory/packages/frontend/src/lib/mock-app.ts index a2e0f24..72f558c 100644 --- a/factory/packages/frontend/src/lib/mock-app.ts +++ b/factory/packages/frontend/src/lib/mock-app.ts @@ -10,6 +10,8 @@ import type { FactoryAppSnapshot, FactoryOrganization } from "@sandbox-agent/fac import { backendClient } from "./backend"; import { frontendClientMode } from "./env"; +const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-factory:remote-app-session"; + const appClient: FactoryAppClient = createFactoryAppClient({ mode: frontendClientMode, backend: frontendClientMode === "remote" ? backendClient : undefined, @@ -31,10 +33,47 @@ export const activeMockUser = currentFactoryUser; export const activeMockOrganization = currentFactoryOrganization; export const eligibleOrganizations = eligibleFactoryOrganizations; +// Track whether the remote client has delivered its first real snapshot. +// Before the first fetch completes the snapshot is the default empty signed_out state, +// so we show a loading screen. Once the fetch returns we know the truth. +let firstSnapshotDelivered = false; + +// The remote client notifies listeners after refresh(), which sets `firstSnapshotDelivered`. +const origSubscribe = appClient.subscribe.bind(appClient); +appClient.subscribe = (listener: () => void): (() => void) => { + const wrappedListener = () => { + firstSnapshotDelivered = true; + listener(); + }; + return origSubscribe(wrappedListener); +}; + +export function isAppSnapshotBootstrapping(snapshot: FactoryAppSnapshot): boolean { + if (frontendClientMode !== "remote" || typeof window === "undefined") { + return false; + } + + const hasStoredSession = window.localStorage.getItem(REMOTE_APP_SESSION_STORAGE_KEY)?.trim().length; + if (!hasStoredSession) { + return false; + } + + // If the backend has already responded and we're still signed_out, the session is stale. + if (firstSnapshotDelivered) { + return false; + } + + // Still waiting for the initial fetch — show the loading screen. + return ( + snapshot.auth.status === "signed_out" && + snapshot.users.length === 0 && + snapshot.organizations.length === 0 + ); +} + export function getMockOrganizationById( snapshot: FactoryAppSnapshot, organizationId: string, ): FactoryOrganization | null { return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null; } - diff --git a/factory/packages/frontend/src/lib/workbench-routing.ts b/factory/packages/frontend/src/lib/workbench-routing.ts index 16dda11..3239636 100644 --- a/factory/packages/frontend/src/lib/workbench-routing.ts +++ b/factory/packages/frontend/src/lib/workbench-routing.ts @@ -1,8 +1,11 @@ -import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared"; +import type { TaskWorkbenchSnapshot } from "@sandbox-agent/factory-shared"; -export function resolveRepoRouteHandoffId( - snapshot: HandoffWorkbenchSnapshot, +export function resolveRepoRouteTaskId( + snapshot: TaskWorkbenchSnapshot, repoId: string, ): string | null { - return snapshot.handoffs.find((handoff) => handoff.repoId === repoId)?.id ?? null; + const tasks = (snapshot as TaskWorkbenchSnapshot & { tasks?: TaskWorkbenchSnapshot["tasks"] }).tasks ?? snapshot.tasks; + return tasks.find((task) => + (task.repoIds?.length ? task.repoIds : [task.repoId]).includes(repoId) + )?.id ?? null; } diff --git a/factory/packages/frontend/src/lib/workbench-runtime.mock.ts b/factory/packages/frontend/src/lib/workbench-runtime.mock.ts index c046478..263c67f 100644 --- a/factory/packages/frontend/src/lib/workbench-runtime.mock.ts +++ b/factory/packages/frontend/src/lib/workbench-runtime.mock.ts @@ -1,10 +1,10 @@ import { - createHandoffWorkbenchClient, - type HandoffWorkbenchClient, + createTaskWorkbenchClient, + type TaskWorkbenchClient, } from "@sandbox-agent/factory-client/workbench"; -export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient { - return createHandoffWorkbenchClient({ +export function createWorkbenchRuntimeClient(workspaceId: string): TaskWorkbenchClient { + return createTaskWorkbenchClient({ mode: "mock", workspaceId, }); diff --git a/factory/packages/frontend/src/lib/workbench-runtime.remote.ts b/factory/packages/frontend/src/lib/workbench-runtime.remote.ts index bcd345f..624d176 100644 --- a/factory/packages/frontend/src/lib/workbench-runtime.remote.ts +++ b/factory/packages/frontend/src/lib/workbench-runtime.remote.ts @@ -1,11 +1,11 @@ import { - createHandoffWorkbenchClient, - type HandoffWorkbenchClient, + createTaskWorkbenchClient, + type TaskWorkbenchClient, } from "@sandbox-agent/factory-client/workbench"; import { backendClient } from "./backend"; -export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient { - return createHandoffWorkbenchClient({ +export function createWorkbenchRuntimeClient(workspaceId: string): TaskWorkbenchClient { + return createTaskWorkbenchClient({ mode: "remote", backend: backendClient, workspaceId, diff --git a/factory/packages/frontend/src/lib/workbench.test.ts b/factory/packages/frontend/src/lib/workbench.test.ts index 8960523..59fa155 100644 --- a/factory/packages/frontend/src/lib/workbench.test.ts +++ b/factory/packages/frontend/src/lib/workbench.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "vitest"; -import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared"; -import { resolveRepoRouteHandoffId } from "./workbench-routing"; +import type { TaskWorkbenchSnapshot } from "@sandbox-agent/factory-shared"; +import { resolveRepoRouteTaskId } from "./workbench-routing"; -const snapshot: HandoffWorkbenchSnapshot = { +const snapshot: TaskWorkbenchSnapshot = { workspaceId: "default", repos: [ { id: "repo-a", label: "acme/repo-a" }, { id: "repo-b", label: "acme/repo-b" }, ], - projects: [], - handoffs: [ + repoSections: [], + tasks: [ { - id: "handoff-a", + id: "task-a", repoId: "repo-a", title: "Alpha", status: "idle", @@ -27,12 +27,12 @@ const snapshot: HandoffWorkbenchSnapshot = { ], }; -describe("resolveRepoRouteHandoffId", () => { - it("finds the active handoff for a repo route", () => { - expect(resolveRepoRouteHandoffId(snapshot, "repo-a")).toBe("handoff-a"); +describe("resolveRepoRouteTaskId", () => { + it("finds the active task for a repo route", () => { + expect(resolveRepoRouteTaskId(snapshot, "repo-a")).toBe("task-a"); }); - it("returns null when a repo has no handoff yet", () => { - expect(resolveRepoRouteHandoffId(snapshot, "repo-b")).toBeNull(); + it("returns null when a repo has no task yet", () => { + expect(resolveRepoRouteTaskId(snapshot, "repo-b")).toBeNull(); }); }); diff --git a/factory/packages/frontend/src/lib/workbench.ts b/factory/packages/frontend/src/lib/workbench.ts index f504e00..dadb342 100644 --- a/factory/packages/frontend/src/lib/workbench.ts +++ b/factory/packages/frontend/src/lib/workbench.ts @@ -1,11 +1,11 @@ -import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client/workbench"; +import type { TaskWorkbenchClient } from "@sandbox-agent/factory-client/workbench"; import { createWorkbenchRuntimeClient } from "@workbench-runtime"; import { frontendClientMode } from "./env"; -export { resolveRepoRouteHandoffId } from "./workbench-routing"; +export { resolveRepoRouteTaskId } from "./workbench-routing"; -const workbenchClientCache = new Map(); +const workbenchClientCache = new Map(); -export function getHandoffWorkbenchClient(workspaceId: string): HandoffWorkbenchClient { +export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient { const cacheKey = `${frontendClientMode}:${workspaceId}`; const existing = workbenchClientCache.get(cacheKey); if (existing) { diff --git a/factory/packages/shared/src/app-shell.ts b/factory/packages/shared/src/app-shell.ts index 92439be..ed87c29 100644 --- a/factory/packages/shared/src/app-shell.ts +++ b/factory/packages/shared/src/app-shell.ts @@ -1,7 +1,7 @@ -export type FactoryBillingPlanId = "free" | "team" | "enterprise"; +export type FactoryBillingPlanId = "free" | "team"; export type FactoryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; -export type FactoryRepoImportStatus = "ready" | "not_started" | "importing"; export type FactoryGithubInstallationStatus = "connected" | "install_required" | "reconnect_required"; +export type FactoryGithubSyncStatus = "pending" | "syncing" | "synced" | "error"; export type FactoryOrganizationKind = "personal" | "organization"; export interface FactoryUser { @@ -43,8 +43,10 @@ export interface FactoryBillingState { export interface FactoryGithubState { connectedAccount: string; installationStatus: FactoryGithubInstallationStatus; + syncStatus: FactoryGithubSyncStatus; importedRepoCount: number; lastSyncLabel: string; + lastSyncAt: number | null; } export interface FactoryOrganizationSettings { @@ -65,7 +67,6 @@ export interface FactoryOrganization { billing: FactoryBillingState; members: FactoryOrganizationMember[]; seatAssignments: string[]; - repoImportStatus: FactoryRepoImportStatus; repoCatalog: string[]; } @@ -85,4 +86,3 @@ export interface UpdateFactoryOrganizationProfileInput { slug: string; primaryDomain: string; } - diff --git a/factory/packages/shared/src/config.ts b/factory/packages/shared/src/config.ts index 0657f9b..05d5f57 100644 --- a/factory/packages/shared/src/config.ts +++ b/factory/packages/shared/src/config.ts @@ -24,7 +24,7 @@ export const ConfigSchema = z.object({ backend: z.object({ host: z.string().default("127.0.0.1"), port: z.number().int().min(1).max(65535).default(7741), - dbPath: z.string().default("~/.local/share/sandbox-agent-factory/handoff.db"), + dbPath: z.string().default("~/.local/share/sandbox-agent-factory/task.db"), opencode_poll_interval: z.number().default(2), github_poll_interval: z.number().default(30), backup_interval_secs: z.number().default(3600), @@ -32,7 +32,7 @@ export const ConfigSchema = z.object({ }).default({ 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, diff --git a/factory/packages/shared/src/contracts.ts b/factory/packages/shared/src/contracts.ts index a1bc7ee..4306c1f 100644 --- a/factory/packages/shared/src/contracts.ts +++ b/factory/packages/shared/src/contracts.ts @@ -15,7 +15,7 @@ export type RepoId = z.infer; export const RepoRemoteSchema = z.string().min(1).max(2048); export type RepoRemote = z.infer; -export const HandoffStatusSchema = z.enum([ +export const TaskStatusSchema = z.enum([ "init_bootstrap_db", "init_enqueue_provision", "init_ensure_name", @@ -36,9 +36,9 @@ export const HandoffStatusSchema = z.enum([ "kill_destroy_sandbox", "kill_finalize", "killed", - "error" + "error", ]); -export type HandoffStatus = z.infer; +export type TaskStatus = z.infer; export const RepoRecordSchema = z.object({ workspaceId: WorkspaceIdSchema, @@ -55,28 +55,30 @@ export const AddRepoInputSchema = z.object({ }); export type AddRepoInput = z.infer; -export const CreateHandoffInputSchema = z.object({ +export const CreateTaskInputSchema = z.object({ workspaceId: WorkspaceIdSchema, repoId: RepoIdSchema, + repoIds: z.array(RepoIdSchema).min(1).optional(), task: z.string().min(1), explicitTitle: z.string().trim().min(1).optional(), explicitBranchName: z.string().trim().min(1).optional(), providerId: ProviderIdSchema.optional(), agentType: AgentTypeSchema.optional(), - onBranch: z.string().trim().min(1).optional() + onBranch: z.string().trim().min(1).optional(), }); -export type CreateHandoffInput = z.infer; +export type CreateTaskInput = z.infer; -export const HandoffRecordSchema = z.object({ +export const TaskRecordSchema = z.object({ workspaceId: WorkspaceIdSchema, - repoId: z.string().min(1), + repoId: RepoIdSchema, + repoIds: z.array(RepoIdSchema).min(1).optional(), repoRemote: RepoRemoteSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), branchName: z.string().min(1).nullable(), title: z.string().min(1).nullable(), task: z.string().min(1), providerId: ProviderIdSchema, - status: HandoffStatusSchema, + status: TaskStatusSchema, statusMessage: z.string().nullable(), activeSandboxId: z.string().nullable(), activeSessionId: z.string().nullable(), @@ -89,7 +91,7 @@ export const HandoffRecordSchema = z.object({ cwd: z.string().nullable(), createdAt: z.number().int(), updatedAt: z.number().int(), - }) + }), ), agentType: z.string().nullable(), prSubmitted: z.boolean(), @@ -103,40 +105,41 @@ export const HandoffRecordSchema = z.object({ hasUnpushed: z.string().nullable(), parentBranch: z.string().nullable(), createdAt: z.number().int(), - updatedAt: z.number().int() + updatedAt: z.number().int(), }); -export type HandoffRecord = z.infer; +export type TaskRecord = z.infer; -export const HandoffSummarySchema = z.object({ +export const TaskSummarySchema = z.object({ workspaceId: WorkspaceIdSchema, - repoId: z.string().min(1), - handoffId: z.string().min(1), + repoId: RepoIdSchema, + repoIds: z.array(RepoIdSchema).min(1).optional(), + taskId: z.string().min(1), branchName: z.string().min(1).nullable(), title: z.string().min(1).nullable(), - status: HandoffStatusSchema, - updatedAt: z.number().int() + status: TaskStatusSchema, + updatedAt: z.number().int(), }); -export type HandoffSummary = z.infer; +export type TaskSummary = z.infer; -export const HandoffActionInputSchema = z.object({ +export const TaskActionInputSchema = z.object({ workspaceId: WorkspaceIdSchema, - handoffId: z.string().min(1) + taskId: z.string().min(1), }); -export type HandoffActionInput = z.infer; +export type TaskActionInput = z.infer; export const SwitchResultSchema = z.object({ workspaceId: WorkspaceIdSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), providerId: ProviderIdSchema, - switchTarget: z.string().min(1) + switchTarget: z.string().min(1), }); export type SwitchResult = z.infer; -export const ListHandoffsInputSchema = z.object({ +export const ListTasksInputSchema = z.object({ workspaceId: WorkspaceIdSchema, - repoId: RepoIdSchema.optional() + repoId: RepoIdSchema.optional(), }); -export type ListHandoffsInput = z.infer; +export type ListTasksInput = z.infer; export const RepoBranchRecordSchema = z.object({ branchName: z.string().min(1), @@ -146,9 +149,9 @@ export const RepoBranchRecordSchema = z.object({ diffStat: z.string().nullable(), hasUnpushed: z.boolean(), conflictsWithMain: z.boolean(), - handoffId: z.string().nullable(), - handoffTitle: z.string().nullable(), - handoffStatus: HandoffStatusSchema.nullable(), + taskId: z.string().nullable(), + taskTitle: z.string().nullable(), + taskStatus: TaskStatusSchema.nullable(), prNumber: z.number().int().nullable(), prState: z.string().nullable(), prUrl: z.string().nullable(), @@ -157,7 +160,7 @@ export const RepoBranchRecordSchema = z.object({ reviewer: z.string().nullable(), firstSeenAt: z.number().int().nullable(), lastSeenAt: z.number().int().nullable(), - updatedAt: z.number().int() + updatedAt: z.number().int(), }); export type RepoBranchRecord = z.infer; @@ -168,7 +171,7 @@ export const RepoOverviewSchema = z.object({ baseRef: z.string().nullable(), stackAvailable: z.boolean(), fetchedAt: z.number().int(), - branches: z.array(RepoBranchRecordSchema) + branches: z.array(RepoBranchRecordSchema), }); export type RepoOverview = z.infer; @@ -177,7 +180,7 @@ export const RepoStackActionSchema = z.enum([ "restack_repo", "restack_subtree", "rebase_branch", - "reparent_branch" + "reparent_branch", ]); export type RepoStackAction = z.infer; @@ -186,7 +189,7 @@ export const RepoStackActionInputSchema = z.object({ repoId: RepoIdSchema, action: RepoStackActionSchema, branchName: z.string().trim().min(1).optional(), - parentBranch: z.string().trim().min(1).optional() + parentBranch: z.string().trim().min(1).optional(), }); export type RepoStackActionInput = z.infer; @@ -194,12 +197,12 @@ export const RepoStackActionResultSchema = z.object({ action: RepoStackActionSchema, executed: z.boolean(), message: z.string().min(1), - at: z.number().int() + at: z.number().int(), }); export type RepoStackActionResult = z.infer; export const WorkspaceUseInputSchema = z.object({ - workspaceId: WorkspaceIdSchema + workspaceId: WorkspaceIdSchema, }); export type WorkspaceUseInput = z.infer; @@ -207,7 +210,7 @@ export const HistoryQueryInputSchema = z.object({ workspaceId: WorkspaceIdSchema, limit: z.number().int().positive().max(500).optional(), branch: z.string().min(1).optional(), - handoffId: z.string().min(1).optional() + taskId: z.string().min(1).optional(), }); export type HistoryQueryInput = z.infer; @@ -215,38 +218,38 @@ export const HistoryEventSchema = z.object({ id: z.number().int(), workspaceId: WorkspaceIdSchema, repoId: z.string().nullable(), - handoffId: z.string().nullable(), + taskId: z.string().nullable(), branchName: z.string().nullable(), kind: z.string().min(1), payloadJson: z.string().min(1), - createdAt: z.number().int() + createdAt: z.number().int(), }); export type HistoryEvent = z.infer; export const PruneInputSchema = z.object({ workspaceId: WorkspaceIdSchema, dryRun: z.boolean(), - yes: z.boolean() + yes: z.boolean(), }); export type PruneInput = z.infer; export const KillInputSchema = z.object({ workspaceId: WorkspaceIdSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), deleteBranch: z.boolean(), - abandon: z.boolean() + abandon: z.boolean(), }); export type KillInput = z.infer; export const StatuslineInputSchema = z.object({ workspaceId: WorkspaceIdSchema, - format: z.enum(["table", "claude-code"]) + format: z.enum(["table", "claude-code"]), }); export type StatuslineInput = z.infer; export const ListInputSchema = z.object({ workspaceId: WorkspaceIdSchema, format: z.enum(["table", "json"]), - full: z.boolean() + full: z.boolean(), }); export type ListInput = z.infer; diff --git a/factory/packages/shared/src/workbench.ts b/factory/packages/shared/src/workbench.ts index 8e26220..24acd57 100644 --- a/factory/packages/shared/src/workbench.ts +++ b/factory/packages/shared/src/workbench.ts @@ -1,4 +1,4 @@ -export type WorkbenchHandoffStatus = "running" | "idle" | "new" | "archived"; +export type WorkbenchTaskStatus = "running" | "idle" | "new" | "archived"; export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor"; export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3"; @@ -76,11 +76,12 @@ export interface WorkbenchPullRequestSummary { status: "draft" | "ready"; } -export interface WorkbenchHandoff { +export interface WorkbenchTask { id: string; repoId: string; + repoIds?: string[]; title: string; - status: WorkbenchHandoffStatus; + status: WorkbenchTaskStatus; repoName: string; updatedAtMs: number; branch: string | null; @@ -96,18 +97,18 @@ export interface WorkbenchRepo { label: string; } -export interface WorkbenchProjectSection { +export interface WorkbenchRepoSection { id: string; label: string; updatedAtMs: number; - handoffs: WorkbenchHandoff[]; + tasks: WorkbenchTask[]; } -export interface HandoffWorkbenchSnapshot { +export interface TaskWorkbenchSnapshot { workspaceId: string; repos: WorkbenchRepo[]; - projects: WorkbenchProjectSection[]; - handoffs: WorkbenchHandoff[]; + repoSections: WorkbenchRepoSection[]; + tasks: WorkbenchTask[]; } export interface WorkbenchModelOption { @@ -120,62 +121,67 @@ export interface WorkbenchModelGroup { models: WorkbenchModelOption[]; } -export interface HandoffWorkbenchSelectInput { - handoffId: string; +export interface TaskWorkbenchSelectInput { + taskId: string; } -export interface HandoffWorkbenchCreateHandoffInput { +export interface TaskWorkbenchCreateTaskInput { repoId: string; + repoIds?: string[]; task: string; title?: string; branch?: string; model?: WorkbenchModelId; } -export interface HandoffWorkbenchRenameInput { - handoffId: string; +export type TaskWorkbenchCreateInput = TaskWorkbenchCreateTaskInput; + +export interface TaskWorkbenchRenameInput { + taskId: string; value: string; } -export interface HandoffWorkbenchSendMessageInput { - handoffId: string; +export interface TaskWorkbenchSendMessageInput { + taskId: string; tabId: string; text: string; attachments: WorkbenchLineAttachment[]; } -export interface HandoffWorkbenchTabInput { - handoffId: string; +export interface TaskWorkbenchTabInput { + taskId: string; tabId: string; } -export interface HandoffWorkbenchRenameSessionInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchRenameSessionInput extends TaskWorkbenchTabInput { title: string; } -export interface HandoffWorkbenchChangeModelInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchChangeModelInput extends TaskWorkbenchTabInput { model: WorkbenchModelId; } -export interface HandoffWorkbenchUpdateDraftInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchUpdateDraftInput extends TaskWorkbenchTabInput { text: string; attachments: WorkbenchLineAttachment[]; } -export interface HandoffWorkbenchSetSessionUnreadInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchSetSessionUnreadInput extends TaskWorkbenchTabInput { unread: boolean; } -export interface HandoffWorkbenchDiffInput { - handoffId: string; +export interface TaskWorkbenchDiffInput { + taskId: string; path: string; } -export interface HandoffWorkbenchCreateHandoffResponse { - handoffId: string; +export interface TaskWorkbenchCreateTaskResponse { + taskId: string; tabId?: string; } -export interface HandoffWorkbenchAddTabResponse { +export type TaskWorkbenchCreateResponse = TaskWorkbenchCreateTaskResponse; + +export interface TaskWorkbenchAddTabResponse { tabId: string; }