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

@ -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);