feat(factory): finish workbench milestone pass

This commit is contained in:
Nathan Flurry 2026-03-09 16:34:27 -07:00
parent bf282199b5
commit 49cba9e6c2
137 changed files with 819 additions and 338 deletions

View file

@ -17,7 +17,7 @@ coverage/
# Environment
.env
.env.*
.openhandoff/
.sandbox-agent-factory/
# IDE
.idea/

2
.gitignore vendored
View file

@ -51,7 +51,7 @@ Cargo.lock
# Example temp files
.tmp-upload/
*.db
.openhandoff/
.sandbox-agent-factory/
# CLI binaries (downloaded during npm publish)
sdks/cli/platforms/*/bin/

View file

@ -27,7 +27,7 @@ Use `pnpm` workspaces and Turborepo.
- `packages/cli` is fully disabled for active development.
- Do not implement new behavior in `packages/cli` unless explicitly requested.
- Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`.
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@openhandoff/cli`.
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@sandbox-agent/factory-cli`.
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
## Common Commands
@ -37,8 +37,8 @@ Use `pnpm` workspaces and Turborepo.
- 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 @openhandoff/frontend dev`
- Start the frontend against the mock workbench client: `OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend dev`
- 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`
@ -85,10 +85,12 @@ For all Rivet/RivetKit implementation:
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`.
6. Before using, build RivetKit in the rivet repo:
- 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
```
@ -132,7 +134,7 @@ For all Rivet/RivetKit implementation:
- 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 `@openhandoff/client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`.
- CLI/TUI/GUI 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.
@ -144,7 +146,7 @@ For all Rivet/RivetKit implementation:
- Use create semantics only on explicit provisioning/create paths where creating a new actor instance is intended.
- `getOrCreate` is a last resort for create paths when an explicit create API is unavailable; never use it in read/command paths.
- For long-lived cross-actor links (for example sandbox/session runtime access), persist actor identity (`actorId`) and keep a fallback lookup path by actor id.
- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/openhandoff/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed).
- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/sandbox-agent-factory/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed).
- RivetKit actor `c.state` is durable, but in Docker it is stored under `/root/.local/share/rivetkit`. If that path is not persisted, actor state-derived indexes (for example, in `project` actor state) can be lost after container recreation even when other data still exists.
- Workflow history divergence policy:
- Production: never auto-delete actor state to resolve `HistoryDivergedError`; ship explicit workflow migrations (`ctx.removed(...)`, step compatibility).
@ -167,7 +169,7 @@ For all Rivet/RivetKit implementation:
## Config
- Keep config path at `~/.config/openhandoff/config.toml`.
- Keep config path at `~/.config/sandbox-agent-factory/config.toml`.
- Evolve properties in place; do not move config location.
## Project Guidance

View file

@ -5,8 +5,8 @@
1. Clone:
```bash
git clone https://github.com/rivet-dev/openhandoff.git
cd openhandoff
git clone https://github.com/rivet-dev/sandbox-agent-factory.git
cd sandbox-agent-factory
```
2. Install dependencies:
@ -35,7 +35,7 @@ Build local RivetKit before backend changes that depend on Rivet internals:
cd ../rivet
pnpm build -F rivetkit
cd /path/to/openhandoff
cd /path/to/sandbox-agent-factory
just sync-rivetkit
```

View file

@ -22,15 +22,15 @@ COPY packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json packages/rivetki
COPY packages/rivetkit-vendor/runner/package.json packages/rivetkit-vendor/runner/package.json
COPY packages/rivetkit-vendor/runner-protocol/package.json packages/rivetkit-vendor/runner-protocol/package.json
COPY packages/rivetkit-vendor/virtual-websocket/package.json packages/rivetkit-vendor/virtual-websocket/package.json
RUN pnpm fetch --frozen-lockfile --filter @openhandoff/backend...
RUN pnpm fetch --frozen-lockfile --filter @sandbox-agent/factory-backend...
FROM base AS build
COPY --from=deps /pnpm/store /pnpm/store
COPY . .
RUN pnpm install --frozen-lockfile --prefer-offline --filter @openhandoff/backend...
RUN pnpm --filter @openhandoff/shared build
RUN pnpm --filter @openhandoff/backend build
RUN pnpm --filter @openhandoff/backend deploy --prod --legacy /out
RUN pnpm install --frozen-lockfile --prefer-offline --filter @sandbox-agent/factory-backend...
RUN pnpm --filter @sandbox-agent/factory-shared build
RUN pnpm --filter @sandbox-agent/factory-backend build
RUN pnpm --filter @sandbox-agent/factory-backend deploy --prod --legacy /out
FROM oven/bun:1.2 AS runtime
ENV NODE_ENV=production

View file

@ -1,9 +1,7 @@
# OpenHandoff
# Sandbox Agent Factory
TypeScript workspace handoff system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI.
**Documentation**: [openhandoff.dev](https://openhandoff.dev)
## Quick Install
```bash

View file

@ -1,17 +1,17 @@
name: openhandoff
name: sandbox-agent-factory
services:
backend:
build:
context: ..
dockerfile: factory/docker/backend.dev.Dockerfile
image: openhandoff-backend-dev
image: sandbox-agent-factory-backend-dev
working_dir: /app
environment:
HF_BACKEND_HOST: "0.0.0.0"
HF_BACKEND_PORT: "7741"
HF_RIVET_MANAGER_PORT: "8750"
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
RIVETKIT_STORAGE_PATH: "/root/.local/share/sandbox-agent-factory/rivetkit"
# Pass through credentials needed for agent execution + PR creation in dev/e2e.
# Do not hardcode secrets; set these in your environment when starting compose.
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
@ -32,21 +32,21 @@ services:
- "8750:8750"
volumes:
- "..:/app"
# The linked RivetKit checkout resolves from factory packages to /handoff/rivet-checkout in-container.
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
# Override HF_RIVET_CHECKOUT_PATH when the linked Rivet workspace lives outside the default sibling checkout.
- "${HF_RIVET_CHECKOUT_PATH:-../../../handoff/rivet-checkout}:/handoff/rivet-checkout:ro"
# Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev.
- "${HOME}/.codex:/root/.codex"
# Keep backend dependency installs Linux-native instead of using host node_modules.
- "openhandoff_backend_root_node_modules:/app/node_modules"
- "openhandoff_backend_backend_node_modules:/app/factory/packages/backend/node_modules"
- "openhandoff_backend_shared_node_modules:/app/factory/packages/shared/node_modules"
- "openhandoff_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules"
- "openhandoff_backend_typescript_node_modules:/app/sdks/typescript/node_modules"
- "openhandoff_backend_pnpm_store:/root/.local/share/pnpm/store"
- "sandbox-agent-factory_backend_root_node_modules:/app/node_modules"
- "sandbox-agent-factory_backend_backend_node_modules:/app/factory/packages/backend/node_modules"
- "sandbox-agent-factory_backend_shared_node_modules:/app/factory/packages/shared/node_modules"
- "sandbox-agent-factory_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules"
- "sandbox-agent-factory_backend_typescript_node_modules:/app/sdks/typescript/node_modules"
- "sandbox-agent-factory_backend_pnpm_store:/root/.local/share/pnpm/store"
# Persist backend-managed local git clones across container restarts.
- "openhandoff_git_repos:/root/.local/share/openhandoff/repos"
- "sandbox-agent-factory_git_repos:/root/.local/share/sandbox-agent-factory/repos"
# Persist RivetKit local storage across container restarts.
- "openhandoff_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
- "sandbox-agent-factory_rivetkit_storage:/root/.local/share/sandbox-agent-factory/rivetkit"
frontend:
build:
@ -62,29 +62,29 @@ services:
- "4173:4173"
volumes:
- "..:/app"
# Ensure logs in .openhandoff/ persist on the host even if we change source mounts later.
- "./.openhandoff:/app/factory/.openhandoff"
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
# Ensure logs in .sandbox-agent-factory/ persist on the host even if we change source mounts later.
- "./.sandbox-agent-factory:/app/factory/.sandbox-agent-factory"
- "${HF_RIVET_CHECKOUT_PATH:-../../../handoff/rivet-checkout}:/handoff/rivet-checkout:ro"
# Use Linux-native workspace dependencies inside the container instead of host node_modules.
- "openhandoff_node_modules:/app/node_modules"
- "openhandoff_client_node_modules:/app/factory/packages/client/node_modules"
- "openhandoff_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules"
- "openhandoff_frontend_node_modules:/app/factory/packages/frontend/node_modules"
- "openhandoff_shared_node_modules:/app/factory/packages/shared/node_modules"
- "openhandoff_pnpm_store:/tmp/.local/share/pnpm/store"
- "sandbox-agent-factory_node_modules:/app/node_modules"
- "sandbox-agent-factory_client_node_modules:/app/factory/packages/client/node_modules"
- "sandbox-agent-factory_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules"
- "sandbox-agent-factory_frontend_node_modules:/app/factory/packages/frontend/node_modules"
- "sandbox-agent-factory_shared_node_modules:/app/factory/packages/shared/node_modules"
- "sandbox-agent-factory_pnpm_store:/tmp/.local/share/pnpm/store"
volumes:
openhandoff_backend_root_node_modules: {}
openhandoff_backend_backend_node_modules: {}
openhandoff_backend_shared_node_modules: {}
openhandoff_backend_persist_rivet_node_modules: {}
openhandoff_backend_typescript_node_modules: {}
openhandoff_backend_pnpm_store: {}
openhandoff_git_repos: {}
openhandoff_rivetkit_storage: {}
openhandoff_node_modules: {}
openhandoff_client_node_modules: {}
openhandoff_frontend_errors_node_modules: {}
openhandoff_frontend_node_modules: {}
openhandoff_shared_node_modules: {}
openhandoff_pnpm_store: {}
sandbox-agent-factory_backend_root_node_modules: {}
sandbox-agent-factory_backend_backend_node_modules: {}
sandbox-agent-factory_backend_shared_node_modules: {}
sandbox-agent-factory_backend_persist_rivet_node_modules: {}
sandbox-agent-factory_backend_typescript_node_modules: {}
sandbox-agent-factory_backend_pnpm_store: {}
sandbox-agent-factory_git_repos: {}
sandbox-agent-factory_rivetkit_storage: {}
sandbox-agent-factory_node_modules: {}
sandbox-agent-factory_client_node_modules: {}
sandbox-agent-factory_frontend_errors_node_modules: {}
sandbox-agent-factory_frontend_node_modules: {}
sandbox-agent-factory_shared_node_modules: {}
sandbox-agent-factory_pnpm_store: {}

View file

@ -1,16 +1,16 @@
name: openhandoff-preview
name: sandbox-agent-factory-preview
services:
backend:
build:
context: ..
dockerfile: quebec/docker/backend.preview.Dockerfile
image: openhandoff-backend-preview
image: sandbox-agent-factory-backend-preview
environment:
HF_BACKEND_HOST: "0.0.0.0"
HF_BACKEND_PORT: "7841"
HF_RIVET_MANAGER_PORT: "8850"
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
RIVETKIT_STORAGE_PATH: "/root/.local/share/sandbox-agent-factory/rivetkit"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
@ -26,19 +26,19 @@ services:
- "8850:8850"
volumes:
- "${HOME}/.codex:/root/.codex"
- "openhandoff_preview_git_repos:/root/.local/share/openhandoff/repos"
- "openhandoff_preview_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
- "sandbox-agent-factory_preview_git_repos:/root/.local/share/sandbox-agent-factory/repos"
- "sandbox-agent-factory_preview_rivetkit_storage:/root/.local/share/sandbox-agent-factory/rivetkit"
frontend:
build:
context: ..
dockerfile: quebec/docker/frontend.preview.Dockerfile
image: openhandoff-frontend-preview
image: sandbox-agent-factory-frontend-preview
depends_on:
- backend
ports:
- "4273:4273"
volumes:
openhandoff_preview_git_repos: {}
openhandoff_preview_rivetkit_storage: {}
sandbox-agent-factory_preview_git_repos: {}
sandbox-agent-factory_preview_rivetkit_storage: {}

View file

@ -39,4 +39,4 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
WORKDIR /app
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @openhandoff/backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @sandbox-agent/factory-backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]

View file

@ -42,8 +42,8 @@ COPY quebec /workspace/quebec
COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @openhandoff/shared build
RUN pnpm --filter @openhandoff/client build
RUN pnpm --filter @openhandoff/backend build
RUN pnpm --filter @sandbox-agent/factory-shared build
RUN pnpm --filter @sandbox-agent/factory-client build
RUN pnpm --filter @sandbox-agent/factory-backend build
CMD ["bash", "-lc", "git config --global --add safe.directory /workspace/quebec >/dev/null 2>&1 || true; exec bun packages/backend/dist/index.js start --host 0.0.0.0 --port 7841"]

View file

@ -8,4 +8,4 @@ RUN npm install -g pnpm@10.28.2
WORKDIR /app
CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @openhandoff/frontend... && cd factory/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"]
CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @sandbox-agent/factory-frontend... && cd factory/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"]

View file

@ -10,10 +10,10 @@ COPY quebec /workspace/quebec
COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @openhandoff/shared build
RUN pnpm --filter @openhandoff/client build
RUN pnpm --filter @openhandoff/frontend-errors build
RUN pnpm --filter @openhandoff/frontend build
RUN pnpm --filter @sandbox-agent/factory-shared build
RUN pnpm --filter @sandbox-agent/factory-client build
RUN pnpm --filter @sandbox-agent/factory-frontend-errors build
RUN pnpm --filter @sandbox-agent/factory-frontend build
FROM nginx:1.27-alpine

View file

@ -1,5 +1,5 @@
{
"name": "@openhandoff/backend",
"name": "@sandbox-agent/factory-backend",
"version": "0.1.0",
"private": true,
"type": "module",
@ -17,7 +17,7 @@
"@hono/node-server": "^1.19.7",
"@hono/node-ws": "^1.3.0",
"@iarna/toml": "^2.2.5",
"@openhandoff/shared": "workspace:*",
"@sandbox-agent/factory-shared": "workspace:*",
"@sandbox-agent/persist-rivet": "workspace:*",
"drizzle-orm": "^0.44.5",
"hono": "^4.11.9",

View file

@ -1,4 +1,4 @@
import type { AppConfig } from "@openhandoff/shared";
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";

View file

@ -1,4 +1,4 @@
import type { HandoffStatus, ProviderId } from "@openhandoff/shared";
import type { HandoffStatus, ProviderId } from "@sandbox-agent/factory-shared";
export interface HandoffCreatedEvent {
workspaceId: string;

View file

@ -8,7 +8,7 @@ import {
sandboxInstanceKey,
workspaceKey
} from "./keys.js";
import type { ProviderId } from "@openhandoff/shared";
import type { ProviderId } from "@sandbox-agent/factory-shared";
export function actorClient(c: any) {
return c.client();

View file

@ -1,6 +1,6 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type { ProviderId } from "@openhandoff/shared";
import type { ProviderId } from "@sandbox-agent/factory-shared";
import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";

View file

@ -10,7 +10,7 @@ import type {
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchUpdateDraftInput,
ProviderId
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfHandoff } from "../handles.js";
import { handoffDb } from "./db/db.js";

View file

@ -1,6 +1,6 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
import { getOrCreateWorkspace } from "../../handles.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { historyKey } from "../../keys.js";

View file

@ -46,6 +46,8 @@ import {
export { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName } from "./queue.js";
const INIT_ENSURE_NAME_TIMEOUT_MS = 5 * 60_000;
type HandoffQueueName = (typeof HANDOFF_QUEUE_NAMES)[number];
type WorkflowHandler = (loopCtx: any, msg: { name: HandoffQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
@ -75,7 +77,11 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
const body = msg.body;
await loopCtx.removed("init-failed", "step");
try {
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
await loopCtx.step({
name: "init-ensure-name",
timeout: INIT_ENSURE_NAME_TIMEOUT_MS,
run: async () => initEnsureNameActivity(loopCtx),
});
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
const sandbox = await loopCtx.step({

View file

@ -2,7 +2,7 @@
import { and, desc, eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { HistoryEvent } from "@openhandoff/shared";
import type { HistoryEvent } from "@sandbox-agent/factory-shared";
import { selfHistory } from "../handles.js";
import { historyDb } from "./db/db.js";
import { events } from "./db/schema.js";

View file

@ -27,5 +27,5 @@ export function logActorWarning(
...(context ?? {})
};
// eslint-disable-next-line no-console
console.warn("[openhandoff][actor:warn]", payload);
console.warn("[factory][actor:warn]", payload);
}

View file

@ -10,7 +10,7 @@ import type {
RepoOverview,
RepoStackAction,
RepoStackActionResult
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { getActorRuntimeContext } from "../context.js";
import {
getHandoff,
@ -21,7 +21,7 @@ import {
selfProject
} from "../handles.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.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";
@ -125,7 +125,7 @@ export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueNa
async function ensureLocalClone(c: any, remoteUrl: string): Promise<string> {
const { config, driver } = getActorRuntimeContext();
const localPath = openhandoffRepoClonePath(config, c.state.workspaceId, c.state.repoId);
const localPath = factoryRepoClonePath(config, c.state.workspaceId, c.state.repoId);
await driver.git.ensureCloned(remoteUrl, localPath);
c.state.localPath = localPath;
return localPath;

View file

@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
import { eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { ProviderId } from "@openhandoff/shared";
import type { ProviderId } from "@sandbox-agent/factory-shared";
import type { SessionEvent, SessionRecord } from "sandbox-agent";
import { sandboxInstanceDb } from "./db/db.js";
import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js";

View file

@ -27,7 +27,7 @@ import type {
RepoRecord,
SwitchResult,
WorkspaceUseInput
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { getActorRuntimeContext } from "../context.js";
import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";

View file

@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { homedir } from "node:os";
import * as toml from "@iarna/toml";
import { ConfigSchema, type AppConfig } from "@openhandoff/shared";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
export const CONFIG_PATH = `${homedir()}/.config/openhandoff/config.toml`;
export const CONFIG_PATH = `${homedir()}/.config/sandbox-agent-factory/config.toml`;
export function loadConfig(path = CONFIG_PATH): AppConfig {
if (!existsSync(path)) {

View file

@ -1,4 +1,4 @@
import type { AppConfig } from "@openhandoff/shared";
import type { AppConfig } from "@sandbox-agent/factory-shared";
export function defaultWorkspace(config: AppConfig): string {
const ws = config.workspace.default.trim();

View file

@ -29,7 +29,7 @@ export interface ActorSqliteDbOptions<TSchema extends Record<string, unknown>> {
/**
* Override base directory for per-actor SQLite files.
*
* Default: `<cwd>/.openhandoff/backend/sqlite`
* Default: `<cwd>/.sandbox-agent-factory/backend/sqlite`
*/
baseDir?: string;
}
@ -53,7 +53,7 @@ export function actorSqliteDb<TSchema extends Record<string, unknown>>(
}) as unknown as DatabaseProvider<any & RawAccess>;
}
const baseDir = options.baseDir ?? join(process.cwd(), ".openhandoff", "backend", "sqlite");
const baseDir = options.baseDir ?? join(process.cwd(), ".sandbox-agent-factory", "backend", "sqlite");
const migrationsFolder = fileURLToPath(options.migrationsFolderUrl);
return {

View file

@ -28,7 +28,7 @@ function ensureAskpassScript(): string {
return cachedAskpassPath;
}
const dir = mkdtempSync(resolve(tmpdir(), "openhandoff-git-askpass-"));
const dir = mkdtempSync(resolve(tmpdir(), "factory-git-askpass-"));
const path = resolve(dir, "askpass.sh");
// Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password.

View file

@ -1,4 +1,4 @@
import type { AgentType } from "@openhandoff/shared";
import type { AgentType } from "@sandbox-agent/factory-shared";
import type {
ListEventsRequest,
ListPage,
@ -144,7 +144,7 @@ export class SandboxAgentClient {
const modeId = modeIdForAgent(normalized.agent ?? this.agent);
// Codex defaults to a restrictive "read-only" preset in some environments.
// For OpenHandoff automation we need to allow edits + command execution + network
// For Sandbox Agent Factory automation we need to allow edits + command execution + network
// access (git push / PR creation). Use full-access where supported.
//
// If the agent doesn't support session modes, ignore.

View file

@ -205,11 +205,11 @@ export class DaytonaProvider implements SandboxProvider {
image: this.buildSnapshotImage(),
envVars: this.buildEnvVars(),
labels: {
"openhandoff.workspace": req.workspaceId,
"openhandoff.handoff": req.handoffId,
"openhandoff.repo_id": req.repoId,
"openhandoff.repo_remote": req.repoRemote,
"openhandoff.branch": req.branchName,
"factory.workspace": req.workspaceId,
"factory.handoff": req.handoffId,
"factory.repo_id": req.repoId,
"factory.repo_remote": req.repoRemote,
"factory.branch": req.branchName,
},
autoStopInterval: this.config.autoStopInterval,
})
@ -220,7 +220,7 @@ export class DaytonaProvider implements SandboxProvider {
state: sandbox.state ?? null
});
const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
const repoDir = `/home/daytona/sandbox-agent-factory/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
// Prepare a working directory for the agent. This must succeed for the handoff to work.
const installStartedAt = Date.now();
@ -258,8 +258,8 @@ export class DaytonaProvider implements SandboxProvider {
`git fetch origin --prune`,
// The handoff 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 "openhandoff@local" >/dev/null 2>&1 || true`,
`git config user.name "OpenHandoff" >/dev/null 2>&1 || true`,
`git config user.email "factory@local" >/dev/null 2>&1 || true`,
`git config user.name "Sandbox Agent Factory" >/dev/null 2>&1 || true`,
].join("; ")
)}`
].join(" "),
@ -294,12 +294,12 @@ export class DaytonaProvider implements SandboxProvider {
client.getSandbox(req.sandboxId)
);
const labels = info.labels ?? {};
const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId;
const repoId = labels["openhandoff.repo_id"] ?? "";
const handoffId = labels["openhandoff.handoff"] ?? "";
const workspaceId = labels["factory.workspace"] ?? req.workspaceId;
const repoId = labels["factory.repo_id"] ?? "";
const handoffId = labels["factory.handoff"] ?? "";
const cwd =
repoId && handoffId
? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo`
? `/home/daytona/sandbox-agent-factory/${workspaceId}/${repoId}/${handoffId}/repo`
: null;
return {

View file

@ -1,5 +1,5 @@
import type { ProviderId } from "@openhandoff/shared";
import type { AppConfig } from "@openhandoff/shared";
import type { ProviderId } from "@sandbox-agent/factory-shared";
import type { AppConfig } from "@sandbox-agent/factory-shared";
import type { BackendDriver } from "../driver.js";
import { DaytonaProvider } from "./daytona/index.js";
import { LocalProvider } from "./local/index.js";

View file

@ -77,7 +77,7 @@ export class LocalProvider implements SandboxProvider {
private rootDir(): string {
return expandHome(
this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes",
this.config.rootDir?.trim() || "~/.local/share/sandbox-agent-factory/local-sandboxes",
);
}

View file

@ -1,4 +1,4 @@
import type { ProviderId } from "@openhandoff/shared";
import type { ProviderId } from "@sandbox-agent/factory-shared";
export interface ProviderCapabilities {
remote: boolean;

View file

@ -1,4 +1,4 @@
import type { AppConfig } from "@openhandoff/shared";
import type { AppConfig } from "@sandbox-agent/factory-shared";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
@ -9,17 +9,17 @@ function expandPath(input: string): string {
return input;
}
export function openhandoffDataDir(config: AppConfig): string {
export function factoryDataDir(config: AppConfig): string {
// Keep data collocated with the backend DB by default.
const dbPath = expandPath(config.backend.dbPath);
return resolve(dirname(dbPath));
}
export function openhandoffRepoClonePath(
export function factoryRepoClonePath(
config: AppConfig,
workspaceId: string,
repoId: string
): string {
return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId));
return resolve(join(factoryDataDir(config), "repos", workspaceId, repoId));
}

View file

@ -12,7 +12,7 @@ class RecordingDaytonaClient implements DaytonaClientLike {
return {
id: "sandbox-1",
state: "started",
snapshot: "snapshot-openhandoff",
snapshot: "snapshot-factory",
labels: {},
};
}
@ -21,7 +21,7 @@ class RecordingDaytonaClient implements DaytonaClientLike {
return {
id: sandboxId,
state: "started",
snapshot: "snapshot-openhandoff",
snapshot: "snapshot-factory",
labels: {},
};
}
@ -92,9 +92,9 @@ describe("daytona provider snapshot image behavior", () => {
expect(commands).toContain("GIT_TERMINAL_PROMPT=0");
expect(commands).toContain("GIT_ASKPASS=/bin/echo");
expect(handle.metadata.snapshot).toBe("snapshot-openhandoff");
expect(handle.metadata.snapshot).toBe("snapshot-factory");
expect(handle.metadata.image).toBe("ubuntu:24.04");
expect(handle.metadata.cwd).toBe("/home/daytona/openhandoff/default/repo-1/handoff-1/repo");
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/handoff-1/repo");
expect(client.executedCommands.length).toBeGreaterThan(0);
});

View file

@ -27,7 +27,7 @@ describe("validateRemote", () => {
mkdirSync(brokenRepoDir, { recursive: true });
writeFileSync(resolve(brokenRepoDir, ".git"), "gitdir: /definitely/missing/worktree\n", "utf8");
await execFileAsync("git", ["init", remoteRepoDir]);
await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.name", "OpenHandoff Test"]);
await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.name", "Factory Test"]);
await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.email", "test@example.com"]);
writeFileSync(resolve(remoteRepoDir, "README.md"), "# test\n", "utf8");
await execFileAsync("git", ["-C", remoteRepoDir, "add", "README.md"]);

View file

@ -1,6 +1,6 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ConfigSchema, type AppConfig } from "@openhandoff/shared";
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";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ConfigSchema, type AppConfig } from "@openhandoff/shared";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
import { createProviderRegistry } from "../src/providers/index.js";
function makeConfig(): AppConfig {
@ -10,7 +10,7 @@ function makeConfig(): AppConfig {
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/openhandoff/handoff.db",
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,

View file

@ -3,41 +3,41 @@ import { normalizeRemoteUrl, repoIdFromRemote } from "../src/services/repo.js";
describe("normalizeRemoteUrl", () => {
test("accepts GitHub shorthand owner/repo", () => {
expect(normalizeRemoteUrl("rivet-dev/openhandoff")).toBe(
"https://github.com/rivet-dev/openhandoff.git"
expect(normalizeRemoteUrl("rivet-dev/sandbox-agent-factory")).toBe(
"https://github.com/rivet-dev/sandbox-agent-factory.git"
);
});
test("accepts github.com/owner/repo without scheme", () => {
expect(normalizeRemoteUrl("github.com/rivet-dev/openhandoff")).toBe(
"https://github.com/rivet-dev/openhandoff.git"
expect(normalizeRemoteUrl("github.com/rivet-dev/sandbox-agent-factory")).toBe(
"https://github.com/rivet-dev/sandbox-agent-factory.git"
);
});
test("canonicalizes GitHub repo URLs without .git", () => {
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff")).toBe(
"https://github.com/rivet-dev/openhandoff.git"
expect(normalizeRemoteUrl("https://github.com/rivet-dev/sandbox-agent-factory")).toBe(
"https://github.com/rivet-dev/sandbox-agent-factory.git"
);
});
test("canonicalizes GitHub non-clone URLs (e.g. /tree/main)", () => {
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff/tree/main")).toBe(
"https://github.com/rivet-dev/openhandoff.git"
expect(normalizeRemoteUrl("https://github.com/rivet-dev/sandbox-agent-factory/tree/main")).toBe(
"https://github.com/rivet-dev/sandbox-agent-factory.git"
);
});
test("does not rewrite scp-style ssh remotes", () => {
expect(normalizeRemoteUrl("git@github.com:rivet-dev/openhandoff.git")).toBe(
"git@github.com:rivet-dev/openhandoff.git"
expect(normalizeRemoteUrl("git@github.com:rivet-dev/sandbox-agent-factory.git")).toBe(
"git@github.com:rivet-dev/sandbox-agent-factory.git"
);
});
});
describe("repoIdFromRemote", () => {
test("repoId is stable across equivalent GitHub inputs", () => {
const a = repoIdFromRemote("rivet-dev/openhandoff");
const b = repoIdFromRemote("https://github.com/rivet-dev/openhandoff.git");
const c = repoIdFromRemote("https://github.com/rivet-dev/openhandoff/tree/main");
const a = repoIdFromRemote("rivet-dev/sandbox-agent-factory");
const b = repoIdFromRemote("https://github.com/rivet-dev/sandbox-agent-factory.git");
const c = repoIdFromRemote("https://github.com/rivet-dev/sandbox-agent-factory/tree/main");
expect(a).toBe(b);
expect(b).toBe(c);
});

View file

@ -17,7 +17,7 @@ function createRepo(): { repoPath: string } {
const repoPath = mkdtempSync(join(tmpdir(), "hf-isolation-repo-"));
execFileSync("git", ["init"], { cwd: repoPath });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath });
execFileSync("git", ["config", "user.name", "OpenHandoff Test"], { cwd: repoPath });
execFileSync("git", ["config", "user.name", "Factory Test"], { cwd: repoPath });
writeFileSync(join(repoPath, "README.md"), "hello\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoPath });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath });

View file

@ -20,7 +20,7 @@ function locationToNames(entry, names) {
}
for (const t of targets) {
const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${t.actorId}.db`, { readonly: true });
const db = new Database(`/root/.local/share/sandbox-agent-factory/rivetkit/databases/${t.actorId}.db`, { readonly: true });
const token = new TextDecoder().decode(db.query("SELECT value FROM kv WHERE hex(key)=?").get("03").value);
await new Promise((resolve, reject) => {

View file

@ -1,6 +1,6 @@
import { Database } from "bun:sqlite";
const db = new Database("/root/.local/share/openhandoff/rivetkit/databases/2e443238457137bf.db", { readonly: true });
const db = new Database("/root/.local/share/sandbox-agent-factory/rivetkit/databases/2e443238457137bf.db", { readonly: true });
const rows = db.query("SELECT hex(key) as k, value as v FROM kv WHERE hex(key) LIKE ? ORDER BY key").all("07%");
const out = rows.map((r) => {
const bytes = new Uint8Array(r.v);

View file

@ -9,7 +9,7 @@ import { decodeReadRangeWire } from "/rivet-handoff-fixes/rivetkit-typescript/pa
import { readRangeWireToOtlp } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/read-range.ts";
const actorId = "2e443238457137bf";
const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true });
const db = new Database(`/root/.local/share/sandbox-agent-factory/rivetkit/databases/${actorId}.db`, { readonly: true });
const row = db.query("SELECT value FROM kv WHERE hex(key)=?").get("03");
const token = new TextDecoder().decode(row.value);

View file

@ -14,7 +14,7 @@ function decodeAscii(u8) {
}
for (const actorId of actorIds) {
const dbPath = `/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`;
const dbPath = `/root/.local/share/sandbox-agent-factory/rivetkit/databases/${actorId}.db`;
const db = new Database(dbPath, { readonly: true });
const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501");

View file

@ -3,7 +3,7 @@ import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/in
import util from "node:util";
const actorId = "2e443238457137bf";
const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true });
const db = new Database(`/root/.local/share/sandbox-agent-factory/rivetkit/databases/${actorId}.db`, { readonly: true });
const row = db.query("SELECT value FROM kv WHERE hex(key) = ?").get("03");
const token = new TextDecoder().decode(row.value);

View file

@ -1,5 +1,5 @@
{
"name": "@openhandoff/cli",
"name": "@sandbox-agent/factory-cli",
"version": "0.1.0",
"private": true,
"type": "module",
@ -16,8 +16,8 @@
"dependencies": {
"@iarna/toml": "^2.2.5",
"@opentui/core": "^0.1.77",
"@openhandoff/client": "workspace:*",
"@openhandoff/shared": "workspace:*",
"@sandbox-agent/factory-client": "workspace:*",
"@sandbox-agent/factory-shared": "workspace:*",
"zod": "^4.1.5"
},
"devDependencies": {

View file

@ -11,8 +11,8 @@ import {
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkBackendHealth } from "@openhandoff/client";
import type { AppConfig } from "@openhandoff/shared";
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;
@ -39,10 +39,10 @@ function backendStateDir(): string {
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
if (xdgDataHome) {
return join(xdgDataHome, "openhandoff", "backend");
return join(xdgDataHome, "sandbox-agent-factory", "backend");
}
return join(homedir(), ".local", "share", "openhandoff", "backend");
return join(homedir(), ".local", "share", "sandbox-agent-factory", "backend");
}
function backendPidPath(host: string, port: number): string {
@ -214,7 +214,7 @@ function resolveLaunchSpec(host: string, port: number): LaunchSpec {
command: "pnpm",
args: [
"--filter",
"@openhandoff/backend",
"@sandbox-agent/factory-backend",
"exec",
"bun",
"src/index.ts",

View file

@ -2,14 +2,14 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@openhandoff/shared";
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@sandbox-agent/factory-shared";
import {
readBackendMetadata,
createBackendClientFromConfig,
formatRelativeAge,
groupHandoffStatus,
summarizeHandoffs
} from "@openhandoff/client";
} from "@sandbox-agent/factory-client";
import {
ensureBackendRunning,
getBackendStatus,

View file

@ -3,7 +3,7 @@ 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 "@openhandoff/shared";
import type { AppConfig } from "@sandbox-agent/factory-shared";
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
export type ThemeMode = "dark" | "light";
@ -101,7 +101,7 @@ export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeRes
return {
theme: candidate.theme,
name: candidate.name,
source: "openhandoff config",
source: "factory config",
mode
};
}

View file

@ -1,11 +1,11 @@
import type { AppConfig, HandoffRecord } from "@openhandoff/shared";
import type { AppConfig, HandoffRecord } from "@sandbox-agent/factory-shared";
import { spawnSync } from "node:child_process";
import {
createBackendClientFromConfig,
filterHandoffs,
formatRelativeAge,
groupHandoffStatus
} from "@openhandoff/client";
} from "@sandbox-agent/factory-client";
import { CLI_BUILD_ID } from "./build-id.js";
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
@ -338,7 +338,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
const client = createBackendClientFromConfig(config);
const renderer = await createCliRenderer({ exitOnCtrlC: false });
const text = new TextRenderable(renderer, {
id: "openhandoff-switch",
id: "factory-switch",
content: "Loading..."
});
text.fg = themeResolution.theme.text;

View file

@ -2,9 +2,9 @@ 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 "@openhandoff/shared";
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/factory-shared";
export const CONFIG_PATH = `${homedir()}/.config/openhandoff/config.toml`;
export const CONFIG_PATH = `${homedir()}/.config/sandbox-agent-factory/config.toml`;
export function loadConfig(path = CONFIG_PATH): AppConfig {
if (!existsSync(path)) {

View file

@ -20,7 +20,7 @@ vi.mock("node:child_process", async () => {
});
import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js";
import { ConfigSchema, type AppConfig } from "@openhandoff/shared";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string {
const sanitized = host
@ -62,7 +62,7 @@ describe("backend manager", () => {
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/openhandoff/handoff.db",
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,

View file

@ -2,7 +2,7 @@ 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 "@openhandoff/shared";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
import { resolveTuiTheme } from "../src/theme.js";
function withEnv(key: string, value: string | undefined): void {
@ -25,7 +25,7 @@ describe("resolveTuiTheme", () => {
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/openhandoff/handoff.db",
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,
@ -98,7 +98,7 @@ describe("resolveTuiTheme", () => {
expect(resolution.theme.background).toBe("#0a0a0a");
});
it("prefers explicit openhandoff theme override from config", () => {
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"));
@ -107,6 +107,6 @@ describe("resolveTuiTheme", () => {
const resolution = resolveTuiTheme(config, tempDir);
expect(resolution.name).toBe("opencode-default");
expect(resolution.source).toBe("openhandoff config");
expect(resolution.source).toBe("factory config");
});
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@openhandoff/shared";
import { filterHandoffs, fuzzyMatch } from "@openhandoff/client";
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 = {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ConfigSchema } from "@openhandoff/shared";
import { ConfigSchema } from "@sandbox-agent/factory-shared";
import { resolveWorkspace } from "../src/workspace/config.js";
describe("cli workspace resolution", () => {
@ -11,7 +11,7 @@ describe("cli workspace resolution", () => {
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/openhandoff/handoff.db",
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,

View file

@ -1,12 +1,43 @@
{
"name": "@openhandoff/client",
"name": "@sandbox-agent/factory-client",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./backend": {
"types": "./dist/backend.d.ts",
"import": "./dist/backend.js"
},
"./workbench": {
"types": "./dist/workbench.d.ts",
"import": "./dist/workbench.js"
},
"./view-model": {
"types": "./dist/view-model.d.ts",
"import": "./dist/view-model.js"
}
},
"typesVersions": {
"*": {
"backend": [
"dist/backend.d.ts"
],
"view-model": [
"dist/view-model.d.ts"
],
"workbench": [
"dist/workbench.d.ts"
]
}
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"build": "tsup src/index.ts src/backend.ts src/workbench.ts src/view-model.ts --format esm --dts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts",
@ -14,7 +45,7 @@
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts"
},
"dependencies": {
"@openhandoff/shared": "workspace:*",
"@sandbox-agent/factory-shared": "workspace:*",
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit"
},
"devDependencies": {

View file

@ -26,7 +26,7 @@ import type {
RepoStackActionResult,
RepoRecord,
SwitchResult
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";

View file

@ -0,0 +1 @@
export * from "./backend-client.js";

View file

@ -26,7 +26,7 @@ import type {
WorkbenchAgentTab as AgentTab,
WorkbenchHandoff as Handoff,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
function buildTranscriptEvent(params: {
@ -48,10 +48,14 @@ function buildTranscriptEvent(params: {
}
class MockWorkbenchStore implements HandoffWorkbenchClient {
private snapshot = buildInitialMockLayoutViewModel();
private snapshot: HandoffWorkbenchSnapshot;
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(workspaceId: string) {
this.snapshot = buildInitialMockLayoutViewModel(workspaceId);
}
getSnapshot(): HandoffWorkbenchSnapshot {
return this.snapshot;
}
@ -103,6 +107,17 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
...current,
handoffs: [nextHandoff, ...current.handoffs],
}));
const task = input.task.trim();
if (task) {
await this.sendMessage({
handoffId: id,
tabId,
text: task,
attachments: [],
});
}
return { handoffId: id, tabId };
}
@ -149,6 +164,13 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}));
}
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
updatedAtMs: nowMs(),
}));
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => {
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
@ -195,8 +217,11 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
this.updateHandoff(input.handoffId, (currentHandoff) => {
const isFirstOnHandoff = currentHandoff.status === "new";
const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title;
const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch;
const synthesizedTitle = text.length > 50 ? `${text.slice(0, 47)}...` : text;
const newTitle =
isFirstOnHandoff && currentHandoff.title === "New Handoff" ? synthesizedTitle : currentHandoff.title;
const newBranch =
isFirstOnHandoff && !currentHandoff.branch ? `feat/${slugify(synthesizedTitle)}` : currentHandoff.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
@ -435,11 +460,13 @@ function candidateEventIndex(handoff: Handoff, tabId: string): number {
return (tab?.transcript.length ?? 0) + 1;
}
let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null;
const mockWorkbenchClients = new Map<string, HandoffWorkbenchClient>();
export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient {
if (!sharedMockWorkbenchClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore();
export function getMockWorkbenchClient(workspaceId = "default"): HandoffWorkbenchClient {
let client = mockWorkbenchClients.get(workspaceId);
if (!client) {
client = new MockWorkbenchStore(workspaceId);
mockWorkbenchClients.set(workspaceId, client);
}
return sharedMockWorkbenchClient;
return client;
}

View file

@ -12,7 +12,7 @@ import type {
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
} 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";
@ -93,6 +93,11 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
await this.refresh();
}
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.handoffId, "push");
await this.refresh();
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();

View file

@ -1,4 +1,4 @@
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",

View file

@ -12,9 +12,9 @@ import type {
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { getMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type HandoffWorkbenchClientMode = "mock" | "remote";
@ -34,6 +34,7 @@ export interface HandoffWorkbenchClient {
renameBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
publishPr(input: HandoffWorkbenchSelectInput): Promise<void>;
pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
revertFile(input: HandoffWorkbenchDiffInput): Promise<void>;
updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
@ -49,7 +50,7 @@ export function createHandoffWorkbenchClient(
options: CreateHandoffWorkbenchClientOptions,
): HandoffWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
return getMockWorkbenchClient(options.workspaceId);
}
if (!options.backend) {

View file

@ -12,7 +12,7 @@ import type {
WorkbenchProjectSection,
WorkbenchRepo,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
export const MODEL_GROUPS: ModelGroup[] = [
{
@ -913,7 +913,7 @@ export function buildInitialHandoffs(): Handoff[] {
];
}
export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
export function buildInitialMockLayoutViewModel(workspaceId = "default"): HandoffWorkbenchSnapshot {
const repos: WorkbenchRepo[] = [
{ id: "acme-backend", label: "acme/backend" },
{ id: "acme-frontend", label: "acme/frontend" },
@ -921,7 +921,7 @@ export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
];
const handoffs = buildInitialHandoffs();
return {
workspaceId: "default",
workspaceId,
repos,
projects: groupWorkbenchProjects(repos, handoffs),
handoffs,
@ -960,6 +960,5 @@ export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff
updatedAtMs:
project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs,
}))
.filter((project) => project.handoffs.length > 0)
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
}

View file

@ -0,0 +1 @@
export * from "./workbench-client.js";

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { HistoryEvent, RepoOverview } from "@openhandoff/shared";
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord, HistoryEvent } from "@openhandoff/shared";
import type { HandoffRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";

View file

@ -1,13 +1,15 @@
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import type {
HandoffRecord,
HandoffWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1";
@ -21,6 +23,10 @@ function requiredEnv(name: string): string {
return value;
}
function requiredRepoRemote(): string {
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
}
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
const value = process.env[name]?.trim();
switch (value) {
@ -38,14 +44,66 @@ async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function seedSandboxFile(workspaceId: string, handoffId: string, filePath: string, content: string): Promise<void> {
const repoPath = `/root/.local/share/openhandoff/local-sandboxes/${workspaceId}/${handoffId}/repo`;
function backendPortFromEndpoint(endpoint: string): string {
const url = new URL(endpoint);
if (url.port) {
return url.port;
}
return url.protocol === "https:" ? "443" : "80";
}
async function resolveBackendContainerName(endpoint: string): Promise<string | null> {
const explicit = process.env.HF_E2E_BACKEND_CONTAINER?.trim();
if (explicit) {
if (explicit.toLowerCase() === "host") {
return null;
}
return explicit;
}
const { stdout } = await execFileAsync("docker", [
"ps",
"--filter",
`publish=${backendPortFromEndpoint(endpoint)}`,
"--format",
"{{.Names}}",
]);
const containerName = stdout
.split("\n")
.map((line) => line.trim())
.find(Boolean);
return containerName ?? null;
}
function sandboxRepoPath(record: HandoffRecord): string {
const activeSandbox =
record.sandboxes.find((sandbox) => 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}`);
}
return cwd;
}
async function seedSandboxFile(endpoint: string, record: HandoffRecord, filePath: string, content: string): Promise<void> {
const repoPath = sandboxRepoPath(record);
const containerName = await resolveBackendContainerName(endpoint);
if (!containerName) {
const directory =
filePath.includes("/") ? `${repoPath}/${filePath.slice(0, filePath.lastIndexOf("/"))}` : repoPath;
await mkdir(directory, { recursive: true });
await writeFile(`${repoPath}/${filePath}`, `${content}\n`, "utf8");
return;
}
const script = [
`cd ${JSON.stringify(repoPath)}`,
`mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`,
`printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`,
].join(" && ");
await execFileAsync("docker", ["exec", "openhandoff-backend-1", "bash", "-lc", script]);
await execFileAsync("docker", ["exec", containerName, "bash", "-lc", script]);
}
async function poll<T>(
@ -166,7 +224,7 @@ describe("e2e(client): workbench flows", () => {
const endpoint =
process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const repoRemote = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const runId = `wb-${Date.now().toString(36)}`;
const expectedFile = `${runId}.txt`;
@ -215,7 +273,8 @@ describe("e2e(client): workbench flows", () => {
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
await seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId);
const detail = await client.getHandoff(workspaceId, created.handoffId);
await seedSandboxFile(endpoint, detail, expectedFile, runId);
const fileSeeded = await poll(
"seeded sandbox file reflected in workbench",

View file

@ -5,7 +5,7 @@ import type {
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
@ -18,6 +18,10 @@ function requiredEnv(name: string): string {
return value;
}
function requiredRepoRemote(): string {
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
}
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
const value = process.env[name]?.trim();
switch (value) {
@ -196,7 +200,7 @@ describe("e2e(client): workbench load", () => {
async () => {
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const repoRemote = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@openhandoff/shared";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import {
filterHandoffs,
formatRelativeAge,

View file

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import type { BackendClient } from "../src/backend-client.js";
import { createHandoffWorkbenchClient } from "../src/workbench-client.js";
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
describe("createHandoffWorkbenchClient", () => {
it("scopes mock clients by workspace", async () => {
const alpha = createHandoffWorkbenchClient({
mode: "mock",
workspaceId: "mock-alpha",
});
const beta = createHandoffWorkbenchClient({
mode: "mock",
workspaceId: "mock-beta",
});
const alphaInitial = alpha.getSnapshot();
const betaInitial = beta.getSnapshot();
expect(alphaInitial.workspaceId).toBe("mock-alpha");
expect(betaInitial.workspaceId).toBe("mock-beta");
await alpha.createHandoff({
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);
});
it("uses the initial task to bootstrap a new mock handoff session", async () => {
const client = createHandoffWorkbenchClient({
mode: "mock",
workspaceId: "mock-onboarding",
});
const snapshot = client.getSnapshot();
const created = await client.createHandoff({
repoId: snapshot.repos[0]!.id,
task: "Reply with exactly: MOCK_WORKBENCH_READY",
title: "Mock onboarding",
branch: "feat/mock-onboarding",
model: "gpt-4o",
});
const runningHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
expect(runningHandoff).toEqual(
expect.objectContaining({
title: "Mock onboarding",
branch: "feat/mock-onboarding",
status: "running",
}),
);
expect(runningHandoff?.tabs[0]).toEqual(
expect.objectContaining({
id: created.tabId,
created: true,
status: "running",
}),
);
expect(runningHandoff?.tabs[0]?.transcript).toEqual([
expect.objectContaining({
sender: "client",
payload: expect.objectContaining({
method: "session/prompt",
}),
}),
]);
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(
expect.objectContaining({
status: "idle",
unread: true,
}),
);
expect(completedHandoff?.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 }> = [];
let snapshotReads = 0;
const backend = {
async runAction(workspaceId: string, handoffId: string, action: string): Promise<void> {
actions.push({ workspaceId, handoffId, action });
},
async getWorkbench(workspaceId: string) {
snapshotReads += 1;
return {
workspaceId,
repos: [],
projects: [],
handoffs: [],
};
},
subscribeWorkbench(): () => void {
return () => {};
},
} as unknown as BackendClient;
const client = createHandoffWorkbenchClient({
mode: "remote",
backend,
workspaceId: "remote-ws",
});
await client.pushHandoff({ handoffId: "handoff-123" });
expect(actions).toEqual([
{
workspaceId: "remote-ws",
handoffId: "handoff-123",
action: "push",
},
]);
expect(snapshotReads).toBe(1);
});
});

View file

@ -1,5 +1,5 @@
{
"name": "@openhandoff/frontend-errors",
"name": "@sandbox-agent/factory-frontend-errors",
"version": "0.1.0",
"private": true,
"type": "module",

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