mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 00:03:04 +00:00
chore: recover bogota workspace state
This commit is contained in:
parent
5d65013aa5
commit
e08d1b4dca
436 changed files with 172093 additions and 455 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
50
factory/packages/backend/src/integrations/git/index.test.ts
Normal file
50
factory/packages/backend/src/integrations/git/index.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ProviderId } from "@openhandoff/shared";
|
||||
import type { ProviderId } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
remote: boolean;
|
||||
|
|
|
|||
498
factory/packages/backend/src/services/app-state.ts
Normal file
498
factory/packages/backend/src/services/app-state.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
12
factory/packages/backend/src/services/repo.test.ts
Normal file
12
factory/packages/backend/src/services/repo.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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, ""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue