chore: recover bogota workspace state

This commit is contained in:
Nathan Flurry 2026-03-09 19:57:56 -07:00
parent 5d65013aa5
commit e08d1b4dca
436 changed files with 172093 additions and 455 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,7 +1,7 @@
// @ts-nocheck
import { basename } from "node:path";
import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
import { repoLabelFromRemote } from "../../services/repo.js";
import {
getOrCreateHandoffStatusSync,
getOrCreateProject,
@ -50,21 +50,6 @@ export function agentTypeForModel(model: string) {
return "claude";
}
function repoLabelFromRemote(remoteUrl: string): string {
const trimmed = remoteUrl.trim();
try {
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`;
}
} catch {
// ignore
}
return basename(trimmed.replace(/\.git$/, ""));
}
function parseDraftAttachments(value: string | null | undefined): Array<any> {
if (!value) {
return [];

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

@ -8,7 +8,7 @@ import { project } from "./project/index.js";
import { sandboxInstance } from "./sandbox-instance/index.js";
import { workspace } from "./workspace/index.js";
function resolveManagerPort(): number {
export function resolveManagerPort(): number {
const raw = process.env.HF_RIVET_MANAGER_PORT ?? process.env.RIVETKIT_MANAGER_PORT;
if (!raw) {
return 7750;
@ -21,7 +21,7 @@ function resolveManagerPort(): number {
return parsed;
}
function resolveManagerHost(): string {
export function resolveManagerHost(): string {
const raw = process.env.HF_RIVET_MANAGER_HOST ?? process.env.RIVETKIT_MANAGER_HOST;
return raw && raw.trim().length > 0 ? raw.trim() : "0.0.0.0";
}

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,11 +27,11 @@ 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";
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
import { normalizeRemoteUrl, repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
import { handoffLookup, repos, providerProfiles } from "./db/schema.js";
import { agentTypeForModel } from "../handoff/workbench.js";
import { expectQueueResponse } from "../../services/queue.js";
@ -132,20 +132,6 @@ async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
return all;
}
function repoLabelFromRemote(remoteUrl: string): string {
try {
const url = new URL(remoteUrl.startsWith("http") ? remoteUrl : `https://${remoteUrl}`);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`;
}
} catch {
// ignore
}
return remoteUrl;
}
async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })

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

@ -1,7 +1,5 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { db as kvDrizzleDb } from "rivetkit/db/drizzle";
// Keep this file decoupled from RivetKit's internal type export paths.
@ -24,12 +22,21 @@ export type DatabaseProvider<DB> = {
export interface ActorSqliteDbOptions<TSchema extends Record<string, unknown>> {
actorName: string;
schema?: TSchema;
migrations?: unknown;
migrations?: {
journal?: {
entries?: ReadonlyArray<{
idx: number;
when: number;
tag: string;
}>;
};
migrations?: Readonly<Record<string, string>>;
};
migrationsFolderUrl: URL;
/**
* Override base directory for per-actor SQLite files.
*
* Default: `<cwd>/.openhandoff/backend/sqlite`
* Default: `<cwd>/.sandbox-agent-factory/backend/sqlite`
*/
baseDir?: string;
}
@ -53,9 +60,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 migrationsFolder = fileURLToPath(options.migrationsFolderUrl);
const baseDir = options.baseDir ?? join(process.cwd(), ".sandbox-agent-factory", "backend", "sqlite");
return {
createClient: async (ctx) => {
// Keep Bun-only module out of Vitest/Vite's static import graph.
@ -92,10 +97,41 @@ export function actorSqliteDb<TSchema extends Record<string, unknown>>(
},
onMigrate: async (client) => {
const { migrate } = await import("drizzle-orm/bun-sqlite/migrator");
await migrate(client, {
migrationsFolder,
});
await client.execute(
"CREATE TABLE IF NOT EXISTS __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL)",
);
const appliedRows = await client.execute("SELECT hash FROM __drizzle_migrations");
const applied = new Set(
appliedRows
.map((row) => (row && typeof row === "object" && "hash" in row ? String((row as { hash: unknown }).hash) : null))
.filter((value): value is string => value !== null),
);
for (const entry of options.migrations?.journal?.entries ?? []) {
if (applied.has(entry.tag)) {
continue;
}
const sql = options.migrations?.migrations?.[`m${String(entry.idx).padStart(4, "0")}`];
if (!sql) {
continue;
}
const statements = sql
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
for (const statement of statements) {
await client.execute(statement);
}
await client.execute(
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)",
entry.tag,
entry.when ?? Date.now(),
);
}
},
onDestroy: async (client) => {

View file

@ -1,11 +1,15 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { initActorRuntimeContext } from "./actors/context.js";
import { registry } from "./actors/index.js";
import { registry, resolveManagerPort } from "./actors/index.js";
import { workspaceKey } from "./actors/keys.js";
import { loadConfig } from "./config/backend.js";
import { createBackends, createNotificationService } from "./notifications/index.js";
import { createDefaultDriver } from "./driver.js";
import { createProviderRegistry } from "./providers/index.js";
import { createClient } from "rivetkit/client";
import { FactoryAppStore } from "./services/app-state.js";
import type { FactoryBillingPlanId, FactoryOrganization } from "@sandbox-agent/factory-shared";
export interface BackendStartOptions {
host?: string;
@ -45,6 +49,34 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
initActorRuntimeContext(config, providers, notifications, driver);
const inner = registry.serve();
const actorClient = createClient({
endpoint: `http://127.0.0.1:${resolveManagerPort()}`,
disableMetadataLookup: true,
}) as any;
const syncOrganizationRepos = async (organization: FactoryOrganization): Promise<void> => {
const workspace = await actorClient.workspace.getOrCreate(workspaceKey(organization.workspaceId), {
createWithInput: organization.workspaceId,
});
const existing = await workspace.listRepos({ workspaceId: organization.workspaceId });
const existingRemotes = new Set(existing.map((repo: { remoteUrl: string }) => repo.remoteUrl));
for (const repo of organization.repoCatalog) {
const remoteUrl = `mockgithub://${repo}`;
if (existingRemotes.has(remoteUrl)) {
continue;
}
await workspace.addRepo({
workspaceId: organization.workspaceId,
remoteUrl,
});
}
};
const appStore = new FactoryAppStore({
onOrganizationReposReady: syncOrganizationRepos,
});
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
// Wrap in a Hono app mounted at /api/rivet to serve on the backend port.
// Uses Bun.serve — cannot use @hono/node-server because it conflicts with
@ -54,23 +86,115 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
app.use(
"/api/rivet/*",
cors({
origin: "*",
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
origin: (origin) => origin ?? "*",
credentials: true,
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token", "x-factory-session"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Type"],
exposeHeaders: ["Content-Type", "x-factory-session"],
})
);
app.use(
"/api/rivet",
cors({
origin: "*",
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
origin: (origin) => origin ?? "*",
credentials: true,
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token", "x-factory-session"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Type"],
exposeHeaders: ["Content-Type", "x-factory-session"],
})
);
const resolveSessionId = (c: any): string => {
const requested = c.req.header("x-factory-session");
const sessionId = appStore.ensureSession(requested);
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.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.post("/api/rivet/app/sign-out", (c) => {
const sessionId = resolveSessionId(c);
return c.json(appStore.signOut(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")));
});
app.patch("/api/rivet/app/organizations/:organizationId/profile", async (c) => {
const body = await c.req.json();
return c.json(
appStore.updateOrganizationProfile({
organizationId: c.req.param("organizationId"),
displayName: typeof body?.displayName === "string" ? body.displayName : "",
slug: typeof body?.slug === "string" ? body.slug : "",
primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "",
}),
);
});
app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => {
return c.json(await appStore.triggerRepoImport(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/billing/checkout", async (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));
});
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);
}
return c.json(appStore.getSnapshot(sessionId));
});
const proxyManagerRequest = async (c: any) => {
const source = new URL(c.req.url);
const target = new URL(source.pathname.replace(/^\/api\/rivet/, "") + source.search, managerOrigin);
return await fetch(new Request(target.toString(), c.req.raw));
};
const forward = async (c: any) => {
try {
const pathname = new URL(c.req.url).pathname;
if (
pathname === "/api/rivet/actors" ||
pathname.startsWith("/api/rivet/actors/") ||
pathname.startsWith("/api/rivet/gateway/")
) {
return await proxyManagerRequest(c);
}
// RivetKit serverless handler is configured with basePath `/api/rivet` by default.
return await inner.fetch(c.req.raw);
} catch (err) {

View file

@ -0,0 +1,50 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { ensureCloned, validateRemote } from "./index.js";
const execFileAsync = promisify(execFile);
const cleanupPaths = new Set<string>();
afterEach(() => {
for (const path of cleanupPaths) {
rmSync(path, { force: true, recursive: true });
}
cleanupPaths.clear();
});
describe("mock github remotes", () => {
it("validates and clones onboarding repos through a local bare mirror", async () => {
const suffix = randomUUID().slice(0, 8);
const remoteUrl = `mockgithub://vitest-${suffix}/demo-repo`;
const clonePath = join(tmpdir(), `factory-clone-${suffix}`);
const barePath = resolve(
homedir(),
".local",
"share",
"sandbox-agent-factory",
"mock-remotes",
`vitest-${suffix}`,
"demo-repo.git",
);
cleanupPaths.add(clonePath);
cleanupPaths.add(resolve(homedir(), ".local", "share", "sandbox-agent-factory", "mock-remotes", `vitest-${suffix}`));
await validateRemote(remoteUrl);
await ensureCloned(remoteUrl, clonePath);
const { stdout: originStdout } = await execFileAsync("git", ["-C", clonePath, "remote", "get-url", "origin"]);
expect(originStdout.trim()).toContain(barePath);
const { stdout: branchStdout } = await execFileAsync("git", ["-C", clonePath, "branch", "--show-current"]);
expect(branchStdout.trim()).toBe("main");
const { stdout: readmeStdout } = await execFileAsync("git", ["-C", clonePath, "show", "HEAD:README.md"]);
expect(readmeStdout).toContain(`vitest-${suffix}/demo-repo`);
});
});

View file

@ -1,7 +1,8 @@
import { execFile } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
@ -9,6 +10,7 @@ const execFileAsync = promisify(execFile);
const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000;
const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000;
const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
const MOCK_GITHUB_PROTOCOL = "mockgithub:";
function resolveGithubToken(): string | null {
const token =
@ -28,7 +30,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.
@ -73,6 +75,126 @@ export interface BranchSnapshot {
commitSha: string;
}
interface MockRemoteDescriptor {
owner: string;
repo: string;
barePath: string;
bareFileUrl: string;
}
function resolveMockRemote(remoteUrl: string): MockRemoteDescriptor | null {
try {
const url = new URL(remoteUrl);
if (url.protocol !== MOCK_GITHUB_PROTOCOL) {
return null;
}
const owner = url.hostname.trim();
const repo = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
if (!owner || !repo) {
return null;
}
const barePath = resolve(homedir(), ".local", "share", "sandbox-agent-factory", "mock-remotes", owner, `${repo}.git`);
return {
owner,
repo,
barePath,
bareFileUrl: pathToFileURL(barePath).toString(),
};
} catch {
return null;
}
}
async function ensureMockRemote(remoteUrl: string): Promise<MockRemoteDescriptor | null> {
const descriptor = resolveMockRemote(remoteUrl);
if (!descriptor) {
return null;
}
if (existsSync(descriptor.barePath)) {
return descriptor;
}
mkdirSync(dirname(descriptor.barePath), { recursive: true });
const tempRepoPath = mkdtempSync(resolve(tmpdir(), `factory-mock-${descriptor.owner}-${descriptor.repo}-`));
try {
await execFileAsync("git", ["init", "--bare", descriptor.barePath], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
});
await execFileAsync("git", ["init", "-b", "main", tempRepoPath], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
});
mkdirSync(resolve(tempRepoPath, "src"), { recursive: true });
writeFileSync(
resolve(tempRepoPath, "README.md"),
[`# ${descriptor.owner}/${descriptor.repo}`, "", "Mock imported repository for Factory onboarding flows."].join("\n"),
"utf8",
);
writeFileSync(
resolve(tempRepoPath, "src", "index.ts"),
[
`export const repositoryId = ${JSON.stringify(`${descriptor.owner}/${descriptor.repo}`)};`,
"",
"export function boot() {",
` return ${JSON.stringify(`hello from ${descriptor.owner}/${descriptor.repo}`)};`,
"}",
"",
].join("\n"),
"utf8",
);
await execFileAsync("git", ["-C", tempRepoPath, "add", "."], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
});
await execFileAsync(
"git",
[
"-C",
tempRepoPath,
"-c",
"user.name=Sandbox Agent Factory",
"-c",
"user.email=factory-mock@sandboxagent.dev",
"commit",
"-m",
"Initial commit",
],
{
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
},
);
await execFileAsync("git", ["-C", tempRepoPath, "remote", "add", "origin", descriptor.barePath], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
});
await execFileAsync("git", ["-C", tempRepoPath, "push", "-u", "origin", "main"], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
});
} catch (error) {
rmSync(descriptor.barePath, { force: true, recursive: true });
throw error;
} finally {
rmSync(tempRepoPath, { force: true, recursive: true });
}
return descriptor;
}
export async function fetch(repoPath: string): Promise<void> {
await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], {
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
@ -90,6 +212,9 @@ export async function validateRemote(remoteUrl: string): Promise<void> {
if (!remote) {
throw new Error("remoteUrl is required");
}
if (await ensureMockRemote(remote)) {
return;
}
try {
await execFileAsync("git", ["ls-remote", "--exit-code", remote, "HEAD"], {
// This command does not need repo context. Running from a neutral directory
@ -114,6 +239,8 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
if (!remote) {
throw new Error("remoteUrl is required");
}
const mockRemote = await ensureMockRemote(remote);
const cloneRemote = mockRemote?.bareFileUrl ?? remote;
if (existsSync(targetPath)) {
if (!isGitRepo(targetPath)) {
@ -121,7 +248,7 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
}
// Keep origin aligned with the configured remote URL.
await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], {
await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", cloneRemote], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
env: gitEnv(),
@ -131,7 +258,7 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
}
mkdirSync(dirname(targetPath), { recursive: true });
await execFileAsync("git", ["clone", remote, targetPath], {
await execFileAsync("git", ["clone", cloneRemote, targetPath], {
maxBuffer: 1024 * 1024 * 8,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),

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

@ -0,0 +1,498 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
import type {
FactoryAppSnapshot,
FactoryBillingPlanId,
FactoryOrganization,
FactoryUser,
UpdateFactoryOrganizationProfileInput,
} from "@sandbox-agent/factory-shared";
interface PersistedFactorySession {
sessionId: string;
currentUserId: string | null;
activeOrganizationId: string | null;
}
interface PersistedFactoryAppState {
users: FactoryUser[];
organizations: FactoryOrganization[];
sessions: PersistedFactorySession[];
}
function nowIso(daysFromNow = 0): string {
const value = new Date();
value.setDate(value.getDate() + daysFromNow);
return value.toISOString();
}
function planSeatsIncluded(planId: FactoryBillingPlanId): number {
switch (planId) {
case "free":
return 1;
case "team":
return 5;
case "enterprise":
return 25;
}
}
function buildDefaultState(): PersistedFactoryAppState {
return {
users: [
{
id: "user-nathan",
name: "Nathan",
email: "nathan@acme.dev",
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
},
],
organizations: [
{
id: "personal-nathan",
workspaceId: "personal-nathan",
kind: "personal",
settings: {
displayName: "Nathan",
slug: "nathan",
primaryDomain: "personal",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
connectedAccount: "nathan",
installationStatus: "connected",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
},
billing: {
planId: "free",
status: "active",
seatsIncluded: 1,
trialEndsAt: null,
renewalAt: null,
stripeCustomerId: "cus_remote_personal_nathan",
paymentMethodLabel: "No card required",
invoices: [],
},
members: [
{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
],
seatAssignments: ["nathan@acme.dev"],
repoImportStatus: "ready",
repoCatalog: ["nathan/personal-site"],
},
{
id: "acme",
workspaceId: "acme",
kind: "organization",
settings: {
displayName: "Acme",
slug: "acme",
primaryDomain: "acme.dev",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
connectedAccount: "acme",
installationStatus: "connected",
importedRepoCount: 3,
lastSyncLabel: "Waiting for first import",
},
billing: {
planId: "team",
status: "active",
seatsIncluded: 5,
trialEndsAt: null,
renewalAt: nowIso(18),
stripeCustomerId: "cus_remote_acme_team",
paymentMethodLabel: "Visa ending in 4242",
invoices: [
{ id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" },
],
},
members: [
{ id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
{ id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" },
{ id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" },
],
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
repoImportStatus: "not_started",
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
},
{
id: "rivet",
workspaceId: "rivet",
kind: "organization",
settings: {
displayName: "Rivet",
slug: "rivet",
primaryDomain: "rivet.dev",
seatAccrualMode: "first_prompt",
defaultModel: "o3",
autoImportRepos: true,
},
github: {
connectedAccount: "rivet-dev",
installationStatus: "reconnect_required",
importedRepoCount: 4,
lastSyncLabel: "Sync stalled 2 hours ago",
},
billing: {
planId: "enterprise",
status: "trialing",
seatsIncluded: 25,
trialEndsAt: nowIso(12),
renewalAt: nowIso(12),
stripeCustomerId: "cus_remote_rivet_enterprise",
paymentMethodLabel: "ACH verified",
invoices: [
{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" },
],
},
members: [
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
],
seatAssignments: ["jamie@rivet.dev"],
repoImportStatus: "not_started",
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
},
],
sessions: [],
};
}
function githubRemote(repo: string): string {
return `https://github.com/${repo}.git`;
}
export interface FactoryAppStoreOptions {
filePath?: string;
onOrganizationReposReady?: (organization: FactoryOrganization) => Promise<void>;
}
export class FactoryAppStore {
private readonly filePath: string;
private readonly onOrganizationReposReady?: (organization: FactoryOrganization) => Promise<void>;
private state: PersistedFactoryAppState;
private readonly importTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(options: FactoryAppStoreOptions = {}) {
this.filePath =
options.filePath ??
join(process.cwd(), ".sandbox-agent-factory", "backend", "app-state.json");
this.onOrganizationReposReady = options.onOrganizationReposReady;
this.state = this.loadState();
}
ensureSession(sessionId?: string | null): string {
if (sessionId) {
const existing = this.state.sessions.find((candidate) => candidate.sessionId === sessionId);
if (existing) {
return existing.sessionId;
}
}
const nextSessionId = randomUUID();
this.state.sessions.push({
sessionId: nextSessionId,
currentUserId: null,
activeOrganizationId: null,
});
this.persist();
return nextSessionId;
}
getSnapshot(sessionId: string): FactoryAppSnapshot {
const session = this.requireSession(sessionId);
return {
auth: {
status: session.currentUserId ? "signed_in" : "signed_out",
currentUserId: session.currentUserId,
},
activeOrganizationId: session.activeOrganizationId,
users: this.state.users,
organizations: this.state.organizations,
};
}
signInWithGithub(sessionId: string, userId = "user-nathan"): FactoryAppSnapshot {
const user = this.state.users.find((candidate) => candidate.id === userId);
if (!user) {
throw new Error(`Unknown user: ${userId}`);
}
this.updateSession(sessionId, (session) => ({
...session,
currentUserId: userId,
activeOrganizationId: user.eligibleOrganizationIds.length === 1 ? user.eligibleOrganizationIds[0] ?? null : null,
}));
return this.getSnapshot(sessionId);
}
signOut(sessionId: string): FactoryAppSnapshot {
this.updateSession(sessionId, (session) => ({
...session,
currentUserId: null,
activeOrganizationId: null,
}));
return this.getSnapshot(sessionId);
}
async selectOrganization(sessionId: string, organizationId: string): Promise<FactoryAppSnapshot> {
const session = this.requireSession(sessionId);
const user = this.requireSignedInUser(session);
if (!user.eligibleOrganizationIds.includes(organizationId)) {
throw new Error(`Organization ${organizationId} is not available to ${user.id}`);
}
const organization = this.requireOrganization(organizationId);
this.updateSession(sessionId, (current) => ({
...current,
activeOrganizationId: organizationId,
}));
if (organization.repoImportStatus !== "ready") {
await this.triggerRepoImport(organizationId);
} else if (this.onOrganizationReposReady) {
await this.onOrganizationReposReady(this.requireOrganization(organizationId));
}
return this.getSnapshot(sessionId);
}
updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): FactoryAppSnapshot {
this.updateOrganization(input.organizationId, (organization) => ({
...organization,
settings: {
...organization.settings,
displayName: input.displayName.trim() || organization.settings.displayName,
slug: input.slug.trim() || organization.settings.slug,
primaryDomain: input.primaryDomain.trim() || organization.settings.primaryDomain,
},
}));
return this.snapshotForOrganization(input.organizationId);
}
async triggerRepoImport(organizationId: string): Promise<FactoryAppSnapshot> {
const organization = this.requireOrganization(organizationId);
const existingTimer = this.importTimers.get(organizationId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.updateOrganization(organizationId, (current) => ({
...current,
repoImportStatus: "importing",
github: {
...current.github,
lastSyncLabel: "Importing repository catalog...",
},
}));
const timer = setTimeout(async () => {
this.updateOrganization(organizationId, (current) => ({
...current,
repoImportStatus: "ready",
github: {
...current.github,
importedRepoCount: current.repoCatalog.length,
installationStatus: "connected",
lastSyncLabel: "Synced just now",
},
}));
if (this.onOrganizationReposReady) {
await this.onOrganizationReposReady(this.requireOrganization(organizationId));
}
this.importTimers.delete(organizationId);
}, organization.kind === "personal" ? 100 : 1_250);
this.importTimers.set(organizationId, timer);
return this.snapshotForOrganization(organizationId);
}
completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
planId,
status: "active",
seatsIncluded: planSeatsIncluded(planId),
trialEndsAt: null,
renewalAt: nowIso(30),
paymentMethodLabel: planId === "enterprise" ? "ACH verified" : "Visa ending in 4242",
invoices: [
{
id: `inv-${organizationId}-${Date.now()}`,
label: `${organization.settings.displayName} ${planId} upgrade`,
issuedAt: new Date().toISOString().slice(0, 10),
amountUsd: planId === "team" ? 240 : planId === "enterprise" ? 1200 : 0,
status: "paid",
},
...organization.billing.invoices,
],
},
}));
return this.snapshotForOrganization(organizationId);
}
cancelScheduledRenewal(organizationId: string): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
status: "scheduled_cancel",
},
}));
return this.snapshotForOrganization(organizationId);
}
resumeSubscription(organizationId: string): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
status: "active",
},
}));
return this.snapshotForOrganization(organizationId);
}
reconnectGithub(organizationId: string): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
github: {
...organization.github,
installationStatus: "connected",
lastSyncLabel: "Reconnected just now",
},
}));
return this.snapshotForOrganization(organizationId);
}
recordSeatUsage(workspaceId: string, userEmail: string): void {
const organization = this.state.organizations.find((candidate) => candidate.workspaceId === workspaceId);
if (!organization || organization.seatAssignments.includes(userEmail)) {
return;
}
this.updateOrganization(organization.id, (current) => ({
...current,
seatAssignments: [...current.seatAssignments, userEmail],
}));
}
organizationRepos(organizationId: string): string[] {
return this.requireOrganization(organizationId).repoCatalog.map(githubRemote);
}
findUserEmailForWorkspace(workspaceId: string, sessionId: string): string | null {
const session = this.requireSession(sessionId);
const user = session.currentUserId ? this.state.users.find((candidate) => candidate.id === session.currentUserId) : null;
const organization = this.state.organizations.find((candidate) => candidate.workspaceId === workspaceId);
if (!user || !organization) {
return null;
}
return organization.members.some((member) => member.email === user.email) ? user.email : null;
}
private loadState(): PersistedFactoryAppState {
try {
const raw = readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(raw) as PersistedFactoryAppState;
if (!parsed || typeof parsed !== "object") {
throw new Error("Invalid app state");
}
return parsed;
} catch {
const initial = buildDefaultState();
this.persistState(initial);
return initial;
}
}
private snapshotForOrganization(organizationId: string): FactoryAppSnapshot {
const session = this.state.sessions.find((candidate) => candidate.activeOrganizationId === organizationId);
if (!session) {
return {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
users: this.state.users,
organizations: this.state.organizations,
};
}
return this.getSnapshot(session.sessionId);
}
private updateSession(
sessionId: string,
updater: (session: PersistedFactorySession) => PersistedFactorySession,
): void {
const session = this.requireSession(sessionId);
this.state = {
...this.state,
sessions: this.state.sessions.map((candidate) => (candidate.sessionId === sessionId ? updater(session) : candidate)),
};
this.persist();
}
private updateOrganization(
organizationId: string,
updater: (organization: FactoryOrganization) => FactoryOrganization,
): void {
this.requireOrganization(organizationId);
this.state = {
...this.state,
organizations: this.state.organizations.map((candidate) =>
candidate.id === organizationId ? updater(candidate) : candidate,
),
};
this.persist();
}
private requireSession(sessionId: string): PersistedFactorySession {
const session = this.state.sessions.find((candidate) => candidate.sessionId === sessionId);
if (!session) {
throw new Error(`Unknown app session: ${sessionId}`);
}
return session;
}
private requireOrganization(organizationId: string): FactoryOrganization {
const organization = this.state.organizations.find((candidate) => candidate.id === organizationId);
if (!organization) {
throw new Error(`Unknown organization: ${organizationId}`);
}
return organization;
}
private requireSignedInUser(session: PersistedFactorySession): FactoryUser {
if (!session.currentUserId) {
throw new Error("User must be signed in");
}
const user = this.state.users.find((candidate) => candidate.id === session.currentUserId);
if (!user) {
throw new Error(`Unknown user: ${session.currentUserId}`);
}
return user;
}
private persist(): void {
this.persistState(this.state);
}
private persistState(state: PersistedFactoryAppState): void {
mkdirSync(dirname(this.filePath), { recursive: true });
writeFileSync(this.filePath, JSON.stringify(state, null, 2));
}
}

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

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { repoLabelFromRemote } from "./repo.js";
describe("repoLabelFromRemote", () => {
it("keeps mock github remotes readable", () => {
expect(repoLabelFromRemote("mockgithub://acme/backend")).toBe("acme/backend");
});
it("extracts owner and repo from file urls", () => {
expect(repoLabelFromRemote("file:///tmp/mock-remotes/rivet/agents.git")).toBe("rivet/agents");
});
});

View file

@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { basename, sep } from "node:path";
export function normalizeRemoteUrl(remoteUrl: string): string {
let value = remoteUrl.trim();
@ -48,3 +49,43 @@ export function repoIdFromRemote(remoteUrl: string): string {
const normalized = normalizeRemoteUrl(remoteUrl);
return createHash("sha1").update(normalized).digest("hex").slice(0, 16);
}
export function repoLabelFromRemote(remoteUrl: string): string {
const trimmed = remoteUrl.trim();
if (!trimmed) {
return "";
}
try {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith("file:")) {
const url = new URL(trimmed);
if (url.protocol === "mockgithub:") {
const repo = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "");
if (url.hostname && repo) {
return `${url.hostname}/${repo}`;
}
}
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[parts.length - 2]}/${(parts[parts.length - 1] ?? "").replace(/\.git$/i, "")}`;
}
} else {
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/i, "")}`;
}
}
} catch {
// ignore and fall through to path-based parsing
}
const normalizedPath = trimmed.replace(/\\/g, sep);
const segments = normalizedPath.split(sep).filter(Boolean);
if (segments.length >= 2) {
return `${segments[segments.length - 2]}/${segments[segments.length - 1]!.replace(/\.git$/i, "")}`;
}
return basename(trimmed.replace(/\.git$/i, ""));
}

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