mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 22:03:48 +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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@openhandoff/cli",
|
||||
"name": "@sandbox-agent/factory-cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@opentui/core": "^0.1.77",
|
||||
"@openhandoff/client": "workspace:*",
|
||||
"@openhandoff/shared": "workspace:*",
|
||||
"@sandbox-agent/factory-client": "workspace:*",
|
||||
"@sandbox-agent/factory-shared": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { checkBackendHealth } from "@openhandoff/client";
|
||||
import type { AppConfig } from "@openhandoff/shared";
|
||||
import { checkBackendHealth } from "@sandbox-agent/factory-client";
|
||||
import type { AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import { CLI_BUILD_ID } from "../build-id.js";
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 1_500;
|
||||
|
|
@ -39,10 +39,10 @@ function backendStateDir(): string {
|
|||
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
|
||||
if (xdgDataHome) {
|
||||
return join(xdgDataHome, "openhandoff", "backend");
|
||||
return join(xdgDataHome, "sandbox-agent-factory", "backend");
|
||||
}
|
||||
|
||||
return join(homedir(), ".local", "share", "openhandoff", "backend");
|
||||
return join(homedir(), ".local", "share", "sandbox-agent-factory", "backend");
|
||||
}
|
||||
|
||||
function backendPidPath(host: string, port: number): string {
|
||||
|
|
@ -214,7 +214,7 @@ function resolveLaunchSpec(host: string, port: number): LaunchSpec {
|
|||
command: "pnpm",
|
||||
args: [
|
||||
"--filter",
|
||||
"@openhandoff/backend",
|
||||
"@sandbox-agent/factory-backend",
|
||||
"exec",
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@openhandoff/shared";
|
||||
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import {
|
||||
readBackendMetadata,
|
||||
createBackendClientFromConfig,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus,
|
||||
summarizeHandoffs
|
||||
} from "@openhandoff/client";
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import {
|
||||
ensureBackendRunning,
|
||||
getBackendStatus,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import * as toml from "@iarna/toml";
|
||||
import type { AppConfig } from "@openhandoff/shared";
|
||||
import type { AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
|
||||
|
||||
export type ThemeMode = "dark" | "light";
|
||||
|
|
@ -101,7 +101,7 @@ export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeRes
|
|||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: "openhandoff config",
|
||||
source: "factory config",
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { AppConfig, HandoffRecord } from "@openhandoff/shared";
|
||||
import type { AppConfig, HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
createBackendClientFromConfig,
|
||||
filterHandoffs,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus
|
||||
} from "@openhandoff/client";
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import { CLI_BUILD_ID } from "./build-id.js";
|
||||
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
||||
|
||||
|
|
@ -338,7 +338,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
const client = createBackendClientFromConfig(config);
|
||||
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "openhandoff-switch",
|
||||
id: "factory-switch",
|
||||
content: "Loading..."
|
||||
});
|
||||
text.fg = themeResolution.theme.text;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|||
import { dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import * as toml from "@iarna/toml";
|
||||
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@openhandoff/shared";
|
||||
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export const CONFIG_PATH = `${homedir()}/.config/openhandoff/config.toml`;
|
||||
export const CONFIG_PATH = `${homedir()}/.config/sandbox-agent-factory/config.toml`;
|
||||
|
||||
export function loadConfig(path = CONFIG_PATH): AppConfig {
|
||||
if (!existsSync(path)) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ vi.mock("node:child_process", async () => {
|
|||
});
|
||||
|
||||
import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js";
|
||||
import { ConfigSchema, type AppConfig } from "@openhandoff/shared";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
|
||||
function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string {
|
||||
const sanitized = host
|
||||
|
|
@ -62,7 +62,7 @@ describe("backend manager", () => {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/openhandoff/handoff.db",
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ConfigSchema, type AppConfig } from "@openhandoff/shared";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import { resolveTuiTheme } from "../src/theme.js";
|
||||
|
||||
function withEnv(key: string, value: string | undefined): void {
|
||||
|
|
@ -25,7 +25,7 @@ describe("resolveTuiTheme", () => {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/openhandoff/handoff.db",
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
@ -98,7 +98,7 @@ describe("resolveTuiTheme", () => {
|
|||
expect(resolution.theme.background).toBe("#0a0a0a");
|
||||
});
|
||||
|
||||
it("prefers explicit openhandoff theme override from config", () => {
|
||||
it("prefers explicit factory theme override from config", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
|
@ -107,6 +107,6 @@ describe("resolveTuiTheme", () => {
|
|||
const resolution = resolveTuiTheme(config, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("openhandoff config");
|
||||
expect(resolution.source).toBe("factory config");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@openhandoff/shared";
|
||||
import { filterHandoffs, fuzzyMatch } from "@openhandoff/client";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { filterHandoffs, fuzzyMatch } from "@sandbox-agent/factory-client";
|
||||
import { formatRows } from "../src/tui.js";
|
||||
|
||||
const sample: HandoffRecord = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ConfigSchema } from "@openhandoff/shared";
|
||||
import { ConfigSchema } from "@sandbox-agent/factory-shared";
|
||||
import { resolveWorkspace } from "../src/workspace/config.js";
|
||||
|
||||
describe("cli workspace resolution", () => {
|
||||
|
|
@ -11,7 +11,7 @@ describe("cli workspace resolution", () => {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/openhandoff/handoff.db",
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,43 @@
|
|||
{
|
||||
"name": "@openhandoff/client",
|
||||
"name": "@sandbox-agent/factory-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./backend": {
|
||||
"types": "./dist/backend.d.ts",
|
||||
"import": "./dist/backend.js"
|
||||
},
|
||||
"./workbench": {
|
||||
"types": "./dist/workbench.d.ts",
|
||||
"import": "./dist/workbench.js"
|
||||
},
|
||||
"./view-model": {
|
||||
"types": "./dist/view-model.d.ts",
|
||||
"import": "./dist/view-model.js"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"backend": [
|
||||
"dist/backend.d.ts"
|
||||
],
|
||||
"view-model": [
|
||||
"dist/view-model.d.ts"
|
||||
],
|
||||
"workbench": [
|
||||
"dist/workbench.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"build": "tsup src/index.ts src/backend.ts src/workbench.ts src/view-model.ts --format esm --dts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts",
|
||||
|
|
@ -14,7 +45,7 @@
|
|||
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openhandoff/shared": "workspace:*",
|
||||
"@sandbox-agent/factory-shared": "workspace:*",
|
||||
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
65
factory/packages/client/src/app-client.ts
Normal file
65
factory/packages/client/src/app-client.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type {
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
FactoryOrganization,
|
||||
FactoryUser,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { BackendClient } from "./backend-client.js";
|
||||
import { getMockFactoryAppClient } from "./mock-app.js";
|
||||
import { createRemoteFactoryAppClient } from "./remote/app-client.js";
|
||||
|
||||
export interface FactoryAppClient {
|
||||
getSnapshot(): FactoryAppSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
signInWithGithub(userId?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
selectOrganization(organizationId: string): Promise<void>;
|
||||
updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<void>;
|
||||
triggerRepoImport(organizationId: string): Promise<void>;
|
||||
completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void>;
|
||||
cancelScheduledRenewal(organizationId: string): Promise<void>;
|
||||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateFactoryAppClientOptions {
|
||||
mode: "mock" | "remote";
|
||||
backend?: BackendClient;
|
||||
}
|
||||
|
||||
export function createFactoryAppClient(options: CreateFactoryAppClientOptions): FactoryAppClient {
|
||||
if (options.mode === "mock") {
|
||||
return getMockFactoryAppClient() as unknown as FactoryAppClient;
|
||||
}
|
||||
if (!options.backend) {
|
||||
throw new Error("Remote app client requires a backend client");
|
||||
}
|
||||
return createRemoteFactoryAppClient({ backend: options.backend });
|
||||
}
|
||||
|
||||
export function currentFactoryUser(snapshot: FactoryAppSnapshot): FactoryUser | null {
|
||||
if (!snapshot.auth.currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
|
||||
}
|
||||
|
||||
export function currentFactoryOrganization(snapshot: FactoryAppSnapshot): FactoryOrganization | null {
|
||||
if (!snapshot.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
|
||||
}
|
||||
|
||||
export function eligibleFactoryOrganizations(snapshot: FactoryAppSnapshot): FactoryOrganization[] {
|
||||
const user = currentFactoryUser(snapshot);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eligible = new Set(user.eligibleOrganizationIds);
|
||||
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
|
||||
}
|
||||
|
||||
|
|
@ -3,6 +3,8 @@ import type {
|
|||
AgentType,
|
||||
AddRepoInput,
|
||||
AppConfig,
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
CreateHandoffInput,
|
||||
HandoffRecord,
|
||||
HandoffSummary,
|
||||
|
|
@ -25,8 +27,9 @@ import type {
|
|||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
RepoRecord,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
SwitchResult
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||
|
||||
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
|
||||
|
|
@ -125,6 +128,17 @@ export interface BackendMetadata {
|
|||
}
|
||||
|
||||
export interface BackendClient {
|
||||
getAppSnapshot(): Promise<FactoryAppSnapshot>;
|
||||
signInWithGithub(userId?: string): Promise<FactoryAppSnapshot>;
|
||||
signOutApp(): Promise<FactoryAppSnapshot>;
|
||||
selectAppOrganization(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
updateAppOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<FactoryAppSnapshot>;
|
||||
triggerAppRepoImport(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
reconnectAppGithub(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<FactoryAppSnapshot>;
|
||||
cancelAppScheduledRenewal(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
resumeAppSubscription(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
recordAppSeatUsage(workspaceId: string): Promise<FactoryAppSnapshot>;
|
||||
addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord>;
|
||||
listRepos(workspaceId: string): Promise<RepoRecord[]>;
|
||||
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
|
||||
|
|
@ -347,14 +361,52 @@ async function probeMetadataEndpoint(
|
|||
|
||||
export function createBackendClient(options: BackendClientOptions): BackendClient {
|
||||
let clientPromise: Promise<RivetClient> | null = null;
|
||||
let appSessionId =
|
||||
typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-factory:remote-app-session") : null;
|
||||
const workbenchSubscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
listeners: Set<() => void>;
|
||||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
||||
pollInterval: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
const persistAppSessionId = (nextSessionId: string | null): void => {
|
||||
appSessionId = nextSessionId;
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (nextSessionId) {
|
||||
window.localStorage.setItem("sandbox-agent-factory:remote-app-session", nextSessionId);
|
||||
} else {
|
||||
window.localStorage.removeItem("sandbox-agent-factory:remote-app-session");
|
||||
}
|
||||
};
|
||||
|
||||
const appRequest = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (appSessionId) {
|
||||
headers.set("x-factory-session", appSessionId);
|
||||
}
|
||||
if (init?.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const res = await fetch(`${options.endpoint.replace(/\/$/, "")}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
const nextSessionId = res.headers.get("x-factory-session");
|
||||
if (nextSessionId) {
|
||||
persistAppSessionId(nextSessionId);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`app request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
const getClient = async (): Promise<RivetClient> => {
|
||||
if (clientPromise) {
|
||||
return clientPromise;
|
||||
|
|
@ -373,6 +425,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
requestTimeoutMs: 8_000
|
||||
});
|
||||
|
||||
const isBrowserRuntime = typeof window !== "undefined";
|
||||
// Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint.
|
||||
const candidateEndpoint = metadata.clientEndpoint
|
||||
? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin)
|
||||
|
|
@ -380,10 +433,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
|
||||
// If the manager port isn't reachable from this client (common behind reverse proxies),
|
||||
// fall back to the configured serverless endpoint to avoid hanging requests.
|
||||
const shouldUseCandidate = metadata.clientEndpoint
|
||||
const shouldUseCandidate = metadata.clientEndpoint && !isBrowserRuntime
|
||||
? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500)
|
||||
: true;
|
||||
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
|
||||
: false;
|
||||
const resolvedEndpoint = isBrowserRuntime
|
||||
? options.endpoint
|
||||
: shouldUseCandidate
|
||||
? candidateEndpoint
|
||||
: options.endpoint;
|
||||
|
||||
return createClient({
|
||||
endpoint: resolvedEndpoint,
|
||||
|
|
@ -480,13 +537,27 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
entry = {
|
||||
listeners: new Set(),
|
||||
disposeConnPromise: null,
|
||||
pollInterval: null,
|
||||
};
|
||||
workbenchSubscriptions.set(workspaceId, entry);
|
||||
}
|
||||
|
||||
entry.listeners.add(listener);
|
||||
|
||||
if (!entry.disposeConnPromise) {
|
||||
const isBrowserRuntime = typeof window !== "undefined";
|
||||
if (isBrowserRuntime) {
|
||||
if (!entry.pollInterval) {
|
||||
entry.pollInterval = setInterval(() => {
|
||||
const current = workbenchSubscriptions.get(workspaceId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
for (const currentListener of [...current.listeners]) {
|
||||
currentListener();
|
||||
}
|
||||
}, 1_000);
|
||||
}
|
||||
} else if (!entry.disposeConnPromise) {
|
||||
entry.disposeConnPromise = (async () => {
|
||||
const handle = await workspace(workspaceId);
|
||||
const conn = (handle as any).connect();
|
||||
|
|
@ -519,6 +590,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
}
|
||||
|
||||
workbenchSubscriptions.delete(workspaceId);
|
||||
if (current.pollInterval) {
|
||||
clearInterval(current.pollInterval);
|
||||
current.pollInterval = null;
|
||||
}
|
||||
void current.disposeConnPromise?.then(async (disposeConn) => {
|
||||
await disposeConn?.();
|
||||
});
|
||||
|
|
@ -526,6 +601,80 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
};
|
||||
|
||||
return {
|
||||
async getAppSnapshot(): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>("/app/snapshot");
|
||||
},
|
||||
|
||||
async signInWithGithub(userId?: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>("/app/sign-in", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userId ? { userId } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async signOutApp(): Promise<FactoryAppSnapshot> {
|
||||
const snapshot = await appRequest<FactoryAppSnapshot>("/app/sign-out", { method: "POST" });
|
||||
persistAppSessionId(appSessionId);
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async selectAppOrganization(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/select`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async updateAppOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${input.organizationId}/profile`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async triggerAppRepoImport(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/import`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async reconnectAppGithub(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/reconnect`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async completeAppHostedCheckout(
|
||||
organizationId: string,
|
||||
planId: FactoryBillingPlanId,
|
||||
): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/checkout`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ planId }),
|
||||
});
|
||||
},
|
||||
|
||||
async cancelAppScheduledRenewal(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async resumeAppSubscription(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/resume`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async recordAppSeatUsage(workspaceId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/workspaces/${workspaceId}/seat-usage`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
|
||||
return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl });
|
||||
},
|
||||
|
|
|
|||
1
factory/packages/client/src/backend.ts
Normal file
1
factory/packages/client/src/backend.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./backend-client.js";
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * from "./app-client.js";
|
||||
export * from "./backend-client.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./mock-app.js";
|
||||
export * from "./view-model.js";
|
||||
export * from "./workbench-client.js";
|
||||
|
|
|
|||
598
factory/packages/client/src/mock-app.ts
Normal file
598
factory/packages/client/src/mock-app.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
import { injectMockLatency } from "./mock/latency.js";
|
||||
|
||||
export type MockBillingPlanId = "free" | "team" | "enterprise";
|
||||
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
|
||||
export type MockRepoImportStatus = "ready" | "not_started" | "importing";
|
||||
export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
|
||||
export type MockOrganizationKind = "personal" | "organization";
|
||||
|
||||
export interface MockFactoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
githubLogin: string;
|
||||
roleLabel: string;
|
||||
eligibleOrganizationIds: string[];
|
||||
}
|
||||
|
||||
export interface MockFactoryOrganizationMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
state: "active" | "invited";
|
||||
}
|
||||
|
||||
export interface MockFactoryInvoice {
|
||||
id: string;
|
||||
label: string;
|
||||
issuedAt: string;
|
||||
amountUsd: number;
|
||||
status: "paid" | "open";
|
||||
}
|
||||
|
||||
export interface MockFactoryBillingState {
|
||||
planId: MockBillingPlanId;
|
||||
status: MockBillingStatus;
|
||||
seatsIncluded: number;
|
||||
trialEndsAt: string | null;
|
||||
renewalAt: string | null;
|
||||
stripeCustomerId: string;
|
||||
paymentMethodLabel: string;
|
||||
invoices: MockFactoryInvoice[];
|
||||
}
|
||||
|
||||
export interface MockFactoryGithubState {
|
||||
connectedAccount: string;
|
||||
installationStatus: MockGithubInstallationStatus;
|
||||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
}
|
||||
|
||||
export interface MockFactoryOrganizationSettings {
|
||||
displayName: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
seatAccrualMode: "first_prompt";
|
||||
defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
|
||||
autoImportRepos: boolean;
|
||||
}
|
||||
|
||||
export interface MockFactoryOrganization {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: MockOrganizationKind;
|
||||
settings: MockFactoryOrganizationSettings;
|
||||
github: MockFactoryGithubState;
|
||||
billing: MockFactoryBillingState;
|
||||
members: MockFactoryOrganizationMember[];
|
||||
seatAssignments: string[];
|
||||
repoImportStatus: MockRepoImportStatus;
|
||||
repoCatalog: string[];
|
||||
}
|
||||
|
||||
export interface MockFactoryAppSnapshot {
|
||||
auth: {
|
||||
status: "signed_out" | "signed_in";
|
||||
currentUserId: string | null;
|
||||
};
|
||||
activeOrganizationId: string | null;
|
||||
users: MockFactoryUser[];
|
||||
organizations: MockFactoryOrganization[];
|
||||
}
|
||||
|
||||
export interface UpdateMockOrganizationProfileInput {
|
||||
organizationId: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
}
|
||||
|
||||
export interface MockFactoryAppClient {
|
||||
getSnapshot(): MockFactoryAppSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
signInWithGithub(userId: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
selectOrganization(organizationId: string): Promise<void>;
|
||||
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
|
||||
triggerRepoImport(organizationId: string): Promise<void>;
|
||||
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
|
||||
cancelScheduledRenewal(organizationId: string): Promise<void>;
|
||||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "sandbox-agent-factory:mock-app:v1";
|
||||
|
||||
function isoDate(daysFromNow: number): string {
|
||||
const value = new Date();
|
||||
value.setDate(value.getDate() + daysFromNow);
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function buildDefaultSnapshot(): MockFactoryAppSnapshot {
|
||||
return {
|
||||
auth: {
|
||||
status: "signed_out",
|
||||
currentUserId: null,
|
||||
},
|
||||
activeOrganizationId: null,
|
||||
users: [
|
||||
{
|
||||
id: "user-nathan",
|
||||
name: "Nathan",
|
||||
email: "nathan@acme.dev",
|
||||
githubLogin: "nathan",
|
||||
roleLabel: "Founder",
|
||||
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
|
||||
},
|
||||
{
|
||||
id: "user-maya",
|
||||
name: "Maya",
|
||||
email: "maya@acme.dev",
|
||||
githubLogin: "maya",
|
||||
roleLabel: "Staff Engineer",
|
||||
eligibleOrganizationIds: ["acme"],
|
||||
},
|
||||
{
|
||||
id: "user-jamie",
|
||||
name: "Jamie",
|
||||
email: "jamie@rivet.dev",
|
||||
githubLogin: "jamie",
|
||||
roleLabel: "Platform Lead",
|
||||
eligibleOrganizationIds: ["personal-jamie", "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_mock_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: "Synced 4 minutes ago",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
status: "active",
|
||||
seatsIncluded: 5,
|
||||
trialEndsAt: null,
|
||||
renewalAt: isoDate(18),
|
||||
stripeCustomerId: "cus_mock_acme_team",
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
invoices: [
|
||||
{ id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" },
|
||||
{ id: "inv-acme-000", label: "February 2026", issuedAt: "2026-02-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" },
|
||||
{ id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" },
|
||||
],
|
||||
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
|
||||
repoImportStatus: "not_started",
|
||||
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
|
||||
},
|
||||
{
|
||||
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: isoDate(12),
|
||||
renewalAt: isoDate(12),
|
||||
stripeCustomerId: "cus_mock_rivet_enterprise",
|
||||
paymentMethodLabel: "ACH verified",
|
||||
invoices: [{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
|
||||
},
|
||||
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" },
|
||||
{ id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" },
|
||||
],
|
||||
seatAssignments: ["jamie@rivet.dev"],
|
||||
repoImportStatus: "not_started",
|
||||
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
|
||||
},
|
||||
{
|
||||
id: "personal-jamie",
|
||||
workspaceId: "personal-jamie",
|
||||
kind: "personal",
|
||||
settings: {
|
||||
displayName: "Jamie",
|
||||
slug: "jamie",
|
||||
primaryDomain: "personal",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "claude-opus-4",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "jamie",
|
||||
installationStatus: "connected",
|
||||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced yesterday",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
status: "active",
|
||||
seatsIncluded: 1,
|
||||
trialEndsAt: null,
|
||||
renewalAt: null,
|
||||
stripeCustomerId: "cus_mock_personal_jamie",
|
||||
paymentMethodLabel: "No card required",
|
||||
invoices: [],
|
||||
},
|
||||
members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }],
|
||||
seatAssignments: ["jamie@rivet.dev"],
|
||||
repoImportStatus: "ready",
|
||||
repoCatalog: ["jamie/demo-app"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredSnapshot(): MockFactoryAppSnapshot | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MockFactoryAppSnapshot;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshot(snapshot: MockFactoryAppSnapshot): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
|
||||
function planSeatsIncluded(planId: MockBillingPlanId): number {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return 1;
|
||||
case "team":
|
||||
return 5;
|
||||
case "enterprise":
|
||||
return 25;
|
||||
}
|
||||
}
|
||||
|
||||
class MockFactoryAppStore implements MockFactoryAppClient {
|
||||
private snapshot = parseStoredSnapshot() ?? buildDefaultSnapshot();
|
||||
private listeners = new Set<() => void>();
|
||||
private importTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
getSnapshot(): MockFactoryAppSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async signInWithGithub(userId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const user = this.snapshot.users.find((candidate) => candidate.id === userId);
|
||||
if (!user) {
|
||||
throw new Error(`Unknown mock user ${userId}`);
|
||||
}
|
||||
|
||||
this.updateSnapshot((current) => {
|
||||
const activeOrganizationId =
|
||||
user.eligibleOrganizationIds.length === 1 ? user.eligibleOrganizationIds[0] ?? null : null;
|
||||
return {
|
||||
...current,
|
||||
auth: {
|
||||
status: "signed_in",
|
||||
currentUserId: userId,
|
||||
},
|
||||
activeOrganizationId,
|
||||
};
|
||||
});
|
||||
|
||||
if (user.eligibleOrganizationIds.length === 1) {
|
||||
await this.selectOrganization(user.eligibleOrganizationIds[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
auth: {
|
||||
status: "signed_out",
|
||||
currentUserId: null,
|
||||
},
|
||||
activeOrganizationId: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const org = this.requireOrganization(organizationId);
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
activeOrganizationId: organizationId,
|
||||
}));
|
||||
|
||||
if (org.repoImportStatus !== "ready") {
|
||||
await this.triggerRepoImport(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(input.organizationId);
|
||||
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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async triggerRepoImport(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
const existingTimer = this.importTimers.get(organizationId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
repoImportStatus: "importing",
|
||||
github: {
|
||||
...organization.github,
|
||||
lastSyncLabel: "Importing repository catalog...",
|
||||
},
|
||||
}));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
repoImportStatus: "ready",
|
||||
github: {
|
||||
...organization.github,
|
||||
importedRepoCount: organization.repoCatalog.length,
|
||||
installationStatus: "connected",
|
||||
lastSyncLabel: "Synced just now",
|
||||
},
|
||||
}));
|
||||
this.importTimers.delete(organizationId);
|
||||
}, 1_250);
|
||||
|
||||
this.importTimers.set(organizationId, timer);
|
||||
}
|
||||
|
||||
async completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
planId,
|
||||
status: "active",
|
||||
seatsIncluded: planSeatsIncluded(planId),
|
||||
trialEndsAt: null,
|
||||
renewalAt: isoDate(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,
|
||||
],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async cancelScheduledRenewal(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
status: "scheduled_cancel",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async resumeSubscription(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
status: "active",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
github: {
|
||||
...organization.github,
|
||||
installationStatus: "connected",
|
||||
lastSyncLabel: "Reconnected just now",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
recordSeatUsage(workspaceId: string): void {
|
||||
const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
||||
const currentUser = currentMockUser(this.snapshot);
|
||||
if (!org || !currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (org.seatAssignments.includes(currentUser.email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateOrganization(org.id, (organization) => ({
|
||||
...organization,
|
||||
seatAssignments: [...organization.seatAssignments, currentUser.email],
|
||||
}));
|
||||
}
|
||||
|
||||
private injectAsyncLatency(): Promise<void> {
|
||||
return injectMockLatency();
|
||||
}
|
||||
|
||||
private updateOrganization(
|
||||
organizationId: string,
|
||||
updater: (organization: MockFactoryOrganization) => MockFactoryOrganization,
|
||||
): void {
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
organizations: current.organizations.map((organization) =>
|
||||
organization.id === organizationId ? updater(organization) : organization,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private updateSnapshot(updater: (current: MockFactoryAppSnapshot) => MockFactoryAppSnapshot): void {
|
||||
this.snapshot = updater(this.snapshot);
|
||||
saveSnapshot(this.snapshot);
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private requireOrganization(organizationId: string): MockFactoryOrganization {
|
||||
const organization = this.snapshot.organizations.find((candidate) => candidate.id === organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(`Unknown mock organization ${organizationId}`);
|
||||
}
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
||||
function currentMockUser(snapshot: MockFactoryAppSnapshot): MockFactoryUser | null {
|
||||
if (!snapshot.auth.currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
|
||||
}
|
||||
|
||||
const mockFactoryAppStore = new MockFactoryAppStore();
|
||||
|
||||
export function getMockFactoryAppClient(): MockFactoryAppClient {
|
||||
return mockFactoryAppStore;
|
||||
}
|
||||
|
||||
export function currentMockFactoryUser(snapshot: MockFactoryAppSnapshot): MockFactoryUser | null {
|
||||
return currentMockUser(snapshot);
|
||||
}
|
||||
|
||||
export function currentMockFactoryOrganization(snapshot: MockFactoryAppSnapshot): MockFactoryOrganization | null {
|
||||
if (!snapshot.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
|
||||
}
|
||||
|
||||
export function eligibleMockOrganizations(snapshot: MockFactoryAppSnapshot): MockFactoryOrganization[] {
|
||||
const user = currentMockUser(snapshot);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eligible = new Set(user.eligibleOrganizationIds);
|
||||
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
|
||||
}
|
||||
13
factory/packages/client/src/mock/latency.ts
Normal file
13
factory/packages/client/src/mock/latency.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const MOCK_LATENCY_MIN_MS = 1;
|
||||
const MOCK_LATENCY_MAX_MS = 200;
|
||||
|
||||
export function randomMockLatencyMs(): number {
|
||||
return Math.floor(Math.random() * (MOCK_LATENCY_MAX_MS - MOCK_LATENCY_MIN_MS + 1)) + MOCK_LATENCY_MIN_MS;
|
||||
}
|
||||
|
||||
export function injectMockLatency(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, randomMockLatencyMs());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
slugify,
|
||||
uid,
|
||||
} from "../workbench-model.js";
|
||||
import { getMockFactoryAppClient } from "../mock-app.js";
|
||||
import { injectMockLatency } from "./latency.js";
|
||||
import type {
|
||||
HandoffWorkbenchAddTabResponse,
|
||||
HandoffWorkbenchChangeModelInput,
|
||||
|
|
@ -26,7 +28,7 @@ import type {
|
|||
WorkbenchAgentTab as AgentTab,
|
||||
WorkbenchHandoff as Handoff,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { HandoffWorkbenchClient } from "../workbench-client.js";
|
||||
|
||||
function buildTranscriptEvent(params: {
|
||||
|
|
@ -48,10 +50,14 @@ function buildTranscriptEvent(params: {
|
|||
}
|
||||
|
||||
class MockWorkbenchStore implements HandoffWorkbenchClient {
|
||||
private snapshot = buildInitialMockLayoutViewModel();
|
||||
private snapshot: HandoffWorkbenchSnapshot;
|
||||
private listeners = new Set<() => void>();
|
||||
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(workspaceId: string) {
|
||||
this.snapshot = buildInitialMockLayoutViewModel(workspaceId);
|
||||
}
|
||||
|
||||
getSnapshot(): HandoffWorkbenchSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
|
@ -64,6 +70,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
|
||||
await this.injectAsyncLatency();
|
||||
const id = uid();
|
||||
const tabId = `session-${id}`;
|
||||
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
|
||||
|
|
@ -103,10 +110,22 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
...current,
|
||||
handoffs: [nextHandoff, ...current.handoffs],
|
||||
}));
|
||||
|
||||
const task = input.task.trim();
|
||||
if (task) {
|
||||
await this.sendMessage({
|
||||
handoffId: id,
|
||||
tabId,
|
||||
text: task,
|
||||
attachments: [],
|
||||
});
|
||||
}
|
||||
|
||||
return { handoffId: id, tabId };
|
||||
}
|
||||
|
||||
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => {
|
||||
const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null;
|
||||
if (!targetTab) {
|
||||
|
|
@ -121,6 +140,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`);
|
||||
|
|
@ -129,6 +149,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`);
|
||||
|
|
@ -137,10 +158,12 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() }));
|
||||
}
|
||||
|
||||
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1;
|
||||
this.updateHandoff(input.handoffId, (handoff) => ({
|
||||
...handoff,
|
||||
|
|
@ -149,7 +172,16 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}));
|
||||
}
|
||||
|
||||
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => ({
|
||||
...handoff,
|
||||
updatedAtMs: nowMs(),
|
||||
}));
|
||||
}
|
||||
|
||||
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => {
|
||||
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
|
||||
const nextDiffs = { ...handoff.diffs };
|
||||
|
|
@ -185,6 +217,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const text = input.text.trim();
|
||||
if (!text) {
|
||||
throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`);
|
||||
|
|
@ -192,11 +225,15 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
|
||||
this.assertTab(input.handoffId, input.tabId);
|
||||
const startedAtMs = nowMs();
|
||||
getMockFactoryAppClient().recordSeatUsage(this.snapshot.workspaceId);
|
||||
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
||||
const isFirstOnHandoff = currentHandoff.status === "new";
|
||||
const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title;
|
||||
const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch;
|
||||
const synthesizedTitle = text.length > 50 ? `${text.slice(0, 47)}...` : text;
|
||||
const newTitle =
|
||||
isFirstOnHandoff && currentHandoff.title === "New Handoff" ? synthesizedTitle : currentHandoff.title;
|
||||
const newBranch =
|
||||
isFirstOnHandoff && !currentHandoff.branch ? `feat/${slugify(synthesizedTitle)}` : currentHandoff.branch;
|
||||
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
|
||||
const userEvent = buildTranscriptEvent({
|
||||
sessionId: input.tabId,
|
||||
|
|
@ -286,6 +323,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.assertTab(input.handoffId, input.tabId);
|
||||
const existing = this.pendingTimers.get(input.tabId);
|
||||
if (existing) {
|
||||
|
|
@ -309,6 +347,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
||||
...currentHandoff,
|
||||
tabs: currentHandoff.tabs.map((candidate) =>
|
||||
|
|
@ -318,6 +357,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const title = input.title.trim();
|
||||
if (!title) {
|
||||
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
|
||||
|
|
@ -331,6 +371,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
||||
if (currentHandoff.tabs.length <= 1) {
|
||||
return currentHandoff;
|
||||
|
|
@ -344,6 +385,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
|
||||
await this.injectAsyncLatency();
|
||||
this.assertHandoff(input.handoffId);
|
||||
const nextTab: AgentTab = {
|
||||
id: uid(),
|
||||
|
|
@ -368,6 +410,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
|
||||
if (!group) {
|
||||
throw new Error(`Unable to resolve model provider for ${input.model}`);
|
||||
|
|
@ -428,6 +471,10 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
private injectAsyncLatency(): Promise<void> {
|
||||
return injectMockLatency();
|
||||
}
|
||||
}
|
||||
|
||||
function candidateEventIndex(handoff: Handoff, tabId: string): number {
|
||||
|
|
@ -435,11 +482,13 @@ function candidateEventIndex(handoff: Handoff, tabId: string): number {
|
|||
return (tab?.transcript.length ?? 0) + 1;
|
||||
}
|
||||
|
||||
let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null;
|
||||
const mockWorkbenchClients = new Map<string, HandoffWorkbenchClient>();
|
||||
|
||||
export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient {
|
||||
if (!sharedMockWorkbenchClient) {
|
||||
sharedMockWorkbenchClient = new MockWorkbenchStore();
|
||||
export function getMockWorkbenchClient(workspaceId = "default"): HandoffWorkbenchClient {
|
||||
let client = mockWorkbenchClients.get(workspaceId);
|
||||
if (!client) {
|
||||
client = new MockWorkbenchStore(workspaceId);
|
||||
mockWorkbenchClients.set(workspaceId, client);
|
||||
}
|
||||
return sharedMockWorkbenchClient;
|
||||
return client;
|
||||
}
|
||||
|
|
|
|||
138
factory/packages/client/src/remote/app-client.ts
Normal file
138
factory/packages/client/src/remote/app-client.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type {
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import type { FactoryAppClient } from "../app-client.js";
|
||||
|
||||
export interface RemoteFactoryAppClientOptions {
|
||||
backend: BackendClient;
|
||||
}
|
||||
|
||||
class RemoteFactoryAppStore implements FactoryAppClient {
|
||||
private readonly backend: BackendClient;
|
||||
private snapshot: FactoryAppSnapshot = {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private importPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: RemoteFactoryAppClientOptions) {
|
||||
this.backend = options.backend;
|
||||
}
|
||||
|
||||
getSnapshot(): FactoryAppSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
void this.refresh();
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async signInWithGithub(userId?: string): Promise<void> {
|
||||
this.snapshot = await this.backend.signInWithGithub(userId);
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
this.snapshot = await this.backend.signOutApp();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.selectAppOrganization(organizationId);
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
}
|
||||
|
||||
async updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<void> {
|
||||
this.snapshot = await this.backend.updateAppOrganizationProfile(input);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async triggerRepoImport(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
}
|
||||
|
||||
async completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void> {
|
||||
this.snapshot = await this.backend.completeAppHostedCheckout(organizationId, planId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async cancelScheduledRenewal(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.cancelAppScheduledRenewal(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async resumeSubscription(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.resumeAppSubscription(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.reconnectAppGithub(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async recordSeatUsage(workspaceId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.recordAppSeatUsage(workspaceId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private scheduleImportPollingIfNeeded(): void {
|
||||
if (this.importPollTimeout) {
|
||||
clearTimeout(this.importPollTimeout);
|
||||
this.importPollTimeout = null;
|
||||
}
|
||||
|
||||
if (!this.snapshot.organizations.some((organization) => organization.repoImportStatus === "importing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.importPollTimeout = setTimeout(() => {
|
||||
this.importPollTimeout = null;
|
||||
void this.refresh();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.refreshPromise) {
|
||||
await this.refreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshPromise = (async () => {
|
||||
this.snapshot = await this.backend.getAppSnapshot();
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
|
||||
await this.refreshPromise;
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteFactoryAppClient(
|
||||
options: RemoteFactoryAppClientOptions,
|
||||
): FactoryAppClient {
|
||||
return new RemoteFactoryAppStore(options);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
HandoffWorkbenchSnapshot,
|
||||
HandoffWorkbenchTabInput,
|
||||
HandoffWorkbenchUpdateDraftInput,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import { groupWorkbenchProjects } from "../workbench-model.js";
|
||||
import type { HandoffWorkbenchClient } from "../workbench-client.js";
|
||||
|
|
@ -93,6 +93,11 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
|
|||
await this.refresh();
|
||||
}
|
||||
|
||||
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.backend.runAction(this.workspaceId, input.handoffId, "push");
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
|
||||
await this.backend.revertWorkbenchFile(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
|
|
@ -104,6 +109,7 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
await this.backend.recordAppSeatUsage(this.workspaceId);
|
||||
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
|
||||
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export const HANDOFF_STATUS_GROUPS = [
|
||||
"queued",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import type {
|
|||
HandoffWorkbenchSnapshot,
|
||||
HandoffWorkbenchTabInput,
|
||||
HandoffWorkbenchUpdateDraftInput,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { BackendClient } from "./backend-client.js";
|
||||
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
|
||||
import { getMockWorkbenchClient } from "./mock/workbench-client.js";
|
||||
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
|
||||
|
||||
export type HandoffWorkbenchClientMode = "mock" | "remote";
|
||||
|
|
@ -34,6 +34,7 @@ export interface HandoffWorkbenchClient {
|
|||
renameBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
|
||||
archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
|
||||
publishPr(input: HandoffWorkbenchSelectInput): Promise<void>;
|
||||
pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
|
||||
revertFile(input: HandoffWorkbenchDiffInput): Promise<void>;
|
||||
updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
|
||||
sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
|
||||
|
|
@ -49,7 +50,7 @@ export function createHandoffWorkbenchClient(
|
|||
options: CreateHandoffWorkbenchClientOptions,
|
||||
): HandoffWorkbenchClient {
|
||||
if (options.mode === "mock") {
|
||||
return getSharedMockWorkbenchClient();
|
||||
return getMockWorkbenchClient(options.workspaceId);
|
||||
}
|
||||
|
||||
if (!options.backend) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
WorkbenchProjectSection,
|
||||
WorkbenchRepo,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
|
||||
export const MODEL_GROUPS: ModelGroup[] = [
|
||||
{
|
||||
|
|
@ -913,15 +913,221 @@ export function buildInitialHandoffs(): Handoff[] {
|
|||
];
|
||||
}
|
||||
|
||||
export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
|
||||
const repos: WorkbenchRepo[] = [
|
||||
{ id: "acme-backend", label: "acme/backend" },
|
||||
{ id: "acme-frontend", label: "acme/frontend" },
|
||||
{ id: "acme-infra", label: "acme/infra" },
|
||||
function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: string): Handoff[] {
|
||||
return [
|
||||
{
|
||||
id: "h-personal-1",
|
||||
repoId,
|
||||
title: "Polish onboarding copy",
|
||||
status: "idle",
|
||||
repoName,
|
||||
updatedAtMs: minutesAgo(18),
|
||||
branch: "feat/onboarding-copy",
|
||||
pullRequest: null,
|
||||
tabs: [
|
||||
{
|
||||
id: "personal-t1",
|
||||
sessionId: "personal-t1",
|
||||
sessionName: "Landing page copy",
|
||||
agent: "Claude",
|
||||
model: "claude-sonnet-4",
|
||||
status: "idle",
|
||||
thinkingSinceMs: null,
|
||||
unread: false,
|
||||
created: true,
|
||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: transcriptFromLegacyMessages("personal-t1", [
|
||||
{
|
||||
id: "pm1",
|
||||
role: "user",
|
||||
agent: null,
|
||||
createdAtMs: minutesAgo(22),
|
||||
lines: [`Tighten the hero copy and call-to-action for ${ownerName}'s landing page.`],
|
||||
},
|
||||
{
|
||||
id: "pm2",
|
||||
role: "agent",
|
||||
agent: "claude",
|
||||
createdAtMs: minutesAgo(20),
|
||||
lines: [
|
||||
"Updated the hero copy to focus on speed-to-handoff and clearer user outcomes.",
|
||||
"",
|
||||
"I also adjusted the primary CTA to feel more action-oriented.",
|
||||
],
|
||||
durationMs: 11_000,
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
fileChanges: [
|
||||
{ path: "src/content/home.ts", added: 12, removed: 6, type: "M" },
|
||||
{ path: "src/components/Hero.tsx", added: 8, removed: 3, type: "M" },
|
||||
],
|
||||
diffs: {
|
||||
"src/content/home.ts": [
|
||||
"@@ -1,6 +1,9 @@",
|
||||
"-export const heroHeadline = 'Build AI handoffs faster';",
|
||||
"+export const heroHeadline = 'Ship clean handoffs without the chaos';",
|
||||
" export const heroBody = [",
|
||||
"- 'OpenHandoff keeps context, diffs, and follow-up work in one place.',",
|
||||
"+ 'Review work, keep context, and hand tasks across your team without losing the thread.',",
|
||||
"+ 'Everything stays attached to the repo, the branch, and the transcript.',",
|
||||
" ];",
|
||||
].join("\n"),
|
||||
},
|
||||
fileTree: [
|
||||
{
|
||||
name: "src",
|
||||
path: "src",
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: "components",
|
||||
path: "src/components",
|
||||
isDir: true,
|
||||
children: [{ name: "Hero.tsx", path: "src/components/Hero.tsx", isDir: false }],
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
path: "src/content",
|
||||
isDir: true,
|
||||
children: [{ name: "home.ts", path: "src/content/home.ts", isDir: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const handoffs = buildInitialHandoffs();
|
||||
}
|
||||
|
||||
function buildRivetHandoffs(): Handoff[] {
|
||||
return [
|
||||
{
|
||||
id: "rivet-h1",
|
||||
repoId: "rivet-dashboard",
|
||||
title: "Add billing upgrade affordances",
|
||||
status: "running",
|
||||
repoName: "rivet/dashboard",
|
||||
updatedAtMs: minutesAgo(6),
|
||||
branch: "feat/billing-upgrade-affordances",
|
||||
pullRequest: { number: 183, status: "draft" },
|
||||
tabs: [
|
||||
{
|
||||
id: "rivet-t1",
|
||||
sessionId: "rivet-t1",
|
||||
sessionName: "Upgrade surface",
|
||||
agent: "Codex",
|
||||
model: "o3",
|
||||
status: "running",
|
||||
thinkingSinceMs: minutesAgo(1),
|
||||
unread: false,
|
||||
created: true,
|
||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: transcriptFromLegacyMessages("rivet-t1", [
|
||||
{
|
||||
id: "rm1",
|
||||
role: "user",
|
||||
agent: null,
|
||||
createdAtMs: minutesAgo(8),
|
||||
lines: ["Add an upgrade CTA on the usage banner and thread it into the hosted checkout flow."],
|
||||
},
|
||||
{
|
||||
id: "rm2",
|
||||
role: "agent",
|
||||
agent: "codex",
|
||||
createdAtMs: minutesAgo(7),
|
||||
lines: ["I'm wiring the banner CTA to the checkout route and cleaning up the plan comparison copy."],
|
||||
durationMs: 16_000,
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
fileChanges: [
|
||||
{ path: "src/routes/settings/billing.tsx", added: 34, removed: 8, type: "M" },
|
||||
{ path: "src/components/usage-banner.tsx", added: 12, removed: 0, type: "A" },
|
||||
],
|
||||
diffs: {
|
||||
"src/routes/settings/billing.tsx": [
|
||||
"@@ -14,7 +14,13 @@",
|
||||
" export function BillingSettings() {",
|
||||
"- return <EmptyState />;",
|
||||
"+ return (",
|
||||
"+ <>",
|
||||
"+ <UsageBanner ctaLabel=\"Upgrade with Stripe\" />",
|
||||
"+ <PlanMatrix />",
|
||||
"+ </>",
|
||||
"+ );",
|
||||
" }",
|
||||
].join("\n"),
|
||||
},
|
||||
fileTree: [
|
||||
{
|
||||
name: "src",
|
||||
path: "src",
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: "components",
|
||||
path: "src/components",
|
||||
isDir: true,
|
||||
children: [{ name: "usage-banner.tsx", path: "src/components/usage-banner.tsx", isDir: false }],
|
||||
},
|
||||
{
|
||||
name: "routes",
|
||||
path: "src/routes",
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: "settings",
|
||||
path: "src/routes/settings",
|
||||
isDir: true,
|
||||
children: [{ name: "billing.tsx", path: "src/routes/settings/billing.tsx", isDir: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildInitialMockLayoutViewModel(workspaceId = "default"): HandoffWorkbenchSnapshot {
|
||||
let repos: WorkbenchRepo[];
|
||||
let handoffs: Handoff[];
|
||||
|
||||
switch (workspaceId) {
|
||||
case "personal-nathan":
|
||||
repos = [{ id: "nathan-personal-site", label: "nathan/personal-site" }];
|
||||
handoffs = buildPersonalHandoffs("Nathan", "nathan-personal-site", "nathan/personal-site");
|
||||
break;
|
||||
case "personal-jamie":
|
||||
repos = [{ id: "jamie-demo-app", label: "jamie/demo-app" }];
|
||||
handoffs = buildPersonalHandoffs("Jamie", "jamie-demo-app", "jamie/demo-app");
|
||||
break;
|
||||
case "rivet":
|
||||
repos = [
|
||||
{ id: "rivet-dashboard", label: "rivet/dashboard" },
|
||||
{ id: "rivet-agents", label: "rivet/agents" },
|
||||
{ id: "rivet-billing", label: "rivet/billing" },
|
||||
{ id: "rivet-infrastructure", label: "rivet/infrastructure" },
|
||||
];
|
||||
handoffs = buildRivetHandoffs();
|
||||
break;
|
||||
case "acme":
|
||||
case "default":
|
||||
default:
|
||||
repos = [
|
||||
{ id: "acme-backend", label: "acme/backend" },
|
||||
{ id: "acme-frontend", label: "acme/frontend" },
|
||||
{ id: "acme-infra", label: "acme/infra" },
|
||||
];
|
||||
handoffs = buildInitialHandoffs();
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: "default",
|
||||
workspaceId,
|
||||
repos,
|
||||
projects: groupWorkbenchProjects(repos, handoffs),
|
||||
handoffs,
|
||||
|
|
@ -960,6 +1166,5 @@ export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff
|
|||
updatedAtMs:
|
||||
project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs,
|
||||
}))
|
||||
.filter((project) => project.handoffs.length > 0)
|
||||
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
||||
}
|
||||
|
|
|
|||
1
factory/packages/client/src/workbench.ts
Normal file
1
factory/packages/client/src/workbench.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./workbench-client.js";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HistoryEvent, RepoOverview } from "@openhandoff/shared";
|
||||
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/factory-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord, HistoryEvent } from "@openhandoff/shared";
|
||||
import type { HandoffRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
HandoffRecord,
|
||||
HandoffWorkbenchSnapshot,
|
||||
WorkbenchAgentTab,
|
||||
WorkbenchHandoff,
|
||||
WorkbenchModelId,
|
||||
WorkbenchTranscriptEvent,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1";
|
||||
|
|
@ -21,6 +23,10 @@ function requiredEnv(name: string): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function requiredRepoRemote(): string {
|
||||
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
}
|
||||
|
||||
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
|
||||
const value = process.env[name]?.trim();
|
||||
switch (value) {
|
||||
|
|
@ -38,14 +44,66 @@ async function sleep(ms: number): Promise<void> {
|
|||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function seedSandboxFile(workspaceId: string, handoffId: string, filePath: string, content: string): Promise<void> {
|
||||
const repoPath = `/root/.local/share/openhandoff/local-sandboxes/${workspaceId}/${handoffId}/repo`;
|
||||
function backendPortFromEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint);
|
||||
if (url.port) {
|
||||
return url.port;
|
||||
}
|
||||
return url.protocol === "https:" ? "443" : "80";
|
||||
}
|
||||
|
||||
async function resolveBackendContainerName(endpoint: string): Promise<string | null> {
|
||||
const explicit = process.env.HF_E2E_BACKEND_CONTAINER?.trim();
|
||||
if (explicit) {
|
||||
if (explicit.toLowerCase() === "host") {
|
||||
return null;
|
||||
}
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("docker", [
|
||||
"ps",
|
||||
"--filter",
|
||||
`publish=${backendPortFromEndpoint(endpoint)}`,
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
const containerName = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
return containerName ?? null;
|
||||
}
|
||||
|
||||
function sandboxRepoPath(record: HandoffRecord): string {
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sandbox) => sandbox.sandboxId === record.activeSandboxId) ??
|
||||
record.sandboxes.find((sandbox) => typeof sandbox.cwd === "string" && sandbox.cwd.length > 0);
|
||||
const cwd = activeSandbox?.cwd?.trim();
|
||||
if (!cwd) {
|
||||
throw new Error(`No sandbox cwd is available for handoff ${record.handoffId}`);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
async function seedSandboxFile(endpoint: string, record: HandoffRecord, filePath: string, content: string): Promise<void> {
|
||||
const repoPath = sandboxRepoPath(record);
|
||||
const containerName = await resolveBackendContainerName(endpoint);
|
||||
if (!containerName) {
|
||||
const directory =
|
||||
filePath.includes("/") ? `${repoPath}/${filePath.slice(0, filePath.lastIndexOf("/"))}` : repoPath;
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(`${repoPath}/${filePath}`, `${content}\n`, "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
const script = [
|
||||
`cd ${JSON.stringify(repoPath)}`,
|
||||
`mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`,
|
||||
`printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`,
|
||||
].join(" && ");
|
||||
await execFileAsync("docker", ["exec", "openhandoff-backend-1", "bash", "-lc", script]);
|
||||
await execFileAsync("docker", ["exec", containerName, "bash", "-lc", script]);
|
||||
}
|
||||
|
||||
async function poll<T>(
|
||||
|
|
@ -166,7 +224,7 @@ describe("e2e(client): workbench flows", () => {
|
|||
const endpoint =
|
||||
process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const repoRemote = requiredRepoRemote();
|
||||
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
const runId = `wb-${Date.now().toString(36)}`;
|
||||
const expectedFile = `${runId}.txt`;
|
||||
|
|
@ -215,7 +273,8 @@ describe("e2e(client): workbench flows", () => {
|
|||
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
|
||||
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
|
||||
|
||||
await seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId);
|
||||
const detail = await client.getHandoff(workspaceId, created.handoffId);
|
||||
await seedSandboxFile(endpoint, detail, expectedFile, runId);
|
||||
|
||||
const fileSeeded = await poll(
|
||||
"seeded sandbox file reflected in workbench",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
WorkbenchHandoff,
|
||||
WorkbenchModelId,
|
||||
WorkbenchTranscriptEvent,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
|
||||
|
|
@ -18,6 +18,10 @@ function requiredEnv(name: string): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function requiredRepoRemote(): string {
|
||||
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
}
|
||||
|
||||
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
|
||||
const value = process.env[name]?.trim();
|
||||
switch (value) {
|
||||
|
|
@ -196,7 +200,7 @@ describe("e2e(client): workbench load", () => {
|
|||
async () => {
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const repoRemote = requiredRepoRemote();
|
||||
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
|
||||
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@openhandoff/shared";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import {
|
||||
filterHandoffs,
|
||||
formatRelativeAge,
|
||||
|
|
|
|||
128
factory/packages/client/test/workbench-client.test.ts
Normal file
128
factory/packages/client/test/workbench-client.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { BackendClient } from "../src/backend-client.js";
|
||||
import { createHandoffWorkbenchClient } from "../src/workbench-client.js";
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("createHandoffWorkbenchClient", () => {
|
||||
it("scopes mock clients by workspace", async () => {
|
||||
const alpha = createHandoffWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId: "mock-alpha",
|
||||
});
|
||||
const beta = createHandoffWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId: "mock-beta",
|
||||
});
|
||||
|
||||
const alphaInitial = alpha.getSnapshot();
|
||||
const betaInitial = beta.getSnapshot();
|
||||
expect(alphaInitial.workspaceId).toBe("mock-alpha");
|
||||
expect(betaInitial.workspaceId).toBe("mock-beta");
|
||||
|
||||
await alpha.createHandoff({
|
||||
repoId: alphaInitial.repos[0]!.id,
|
||||
task: "Ship alpha-only change",
|
||||
title: "Alpha only",
|
||||
});
|
||||
|
||||
expect(alpha.getSnapshot().handoffs).toHaveLength(alphaInitial.handoffs.length + 1);
|
||||
expect(beta.getSnapshot().handoffs).toHaveLength(betaInitial.handoffs.length);
|
||||
});
|
||||
|
||||
it("uses the initial task to bootstrap a new mock handoff session", async () => {
|
||||
const client = createHandoffWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId: "mock-onboarding",
|
||||
});
|
||||
const snapshot = client.getSnapshot();
|
||||
|
||||
const created = await client.createHandoff({
|
||||
repoId: snapshot.repos[0]!.id,
|
||||
task: "Reply with exactly: MOCK_WORKBENCH_READY",
|
||||
title: "Mock onboarding",
|
||||
branch: "feat/mock-onboarding",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
|
||||
const runningHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
|
||||
expect(runningHandoff).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "Mock onboarding",
|
||||
branch: "feat/mock-onboarding",
|
||||
status: "running",
|
||||
}),
|
||||
);
|
||||
expect(runningHandoff?.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: created.tabId,
|
||||
created: true,
|
||||
status: "running",
|
||||
}),
|
||||
);
|
||||
expect(runningHandoff?.tabs[0]?.transcript).toEqual([
|
||||
expect.objectContaining({
|
||||
sender: "client",
|
||||
payload: expect.objectContaining({
|
||||
method: "session/prompt",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
await sleep(2_700);
|
||||
|
||||
const completedHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
|
||||
expect(completedHandoff?.status).toBe("idle");
|
||||
expect(completedHandoff?.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "idle",
|
||||
unread: true,
|
||||
}),
|
||||
);
|
||||
expect(completedHandoff?.tabs[0]?.transcript).toEqual([
|
||||
expect.objectContaining({ sender: "client" }),
|
||||
expect.objectContaining({ sender: "agent" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes remote push actions through the backend boundary", async () => {
|
||||
const actions: Array<{ workspaceId: string; handoffId: string; action: string }> = [];
|
||||
let snapshotReads = 0;
|
||||
const backend = {
|
||||
async runAction(workspaceId: string, handoffId: string, action: string): Promise<void> {
|
||||
actions.push({ workspaceId, handoffId, action });
|
||||
},
|
||||
async getWorkbench(workspaceId: string) {
|
||||
snapshotReads += 1;
|
||||
return {
|
||||
workspaceId,
|
||||
repos: [],
|
||||
projects: [],
|
||||
handoffs: [],
|
||||
};
|
||||
},
|
||||
subscribeWorkbench(): () => void {
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as BackendClient;
|
||||
|
||||
const client = createHandoffWorkbenchClient({
|
||||
mode: "remote",
|
||||
backend,
|
||||
workspaceId: "remote-ws",
|
||||
});
|
||||
|
||||
await client.pushHandoff({ handoffId: "handoff-123" });
|
||||
|
||||
expect(actions).toEqual([
|
||||
{
|
||||
workspaceId: "remote-ws",
|
||||
handoffId: "handoff-123",
|
||||
action: "push",
|
||||
},
|
||||
]);
|
||||
expect(snapshotReads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@openhandoff/frontend-errors",
|
||||
"name": "@sandbox-agent/factory-frontend-errors",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ interface FrontendErrorCollectorGlobal {
|
|||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal;
|
||||
__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext;
|
||||
__FACTORY_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal;
|
||||
__FACTORY_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17,11 +17,11 @@ export function setFrontendErrorContext(context: FrontendErrorContext): void {
|
|||
}
|
||||
|
||||
const nextContext = sanitizeContext(context);
|
||||
window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = {
|
||||
...(window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ ?? {}),
|
||||
window.__FACTORY_FRONTEND_ERROR_CONTEXT__ = {
|
||||
...(window.__FACTORY_FRONTEND_ERROR_CONTEXT__ ?? {}),
|
||||
...nextContext,
|
||||
};
|
||||
window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext);
|
||||
window.__FACTORY_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext);
|
||||
}
|
||||
|
||||
function sanitizeContext(input: FrontendErrorContext): FrontendErrorContext {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { dirname, join, resolve } from "node:path";
|
|||
import { Hono } from "hono";
|
||||
import type { FrontendErrorContext, FrontendErrorKind, FrontendErrorLogEvent } from "./types.js";
|
||||
|
||||
const DEFAULT_RELATIVE_LOG_PATH = ".openhandoff/logs/frontend-errors.ndjson";
|
||||
const DEFAULT_REPORTER = "openhandoff-frontend";
|
||||
const DEFAULT_RELATIVE_LOG_PATH = ".sandbox-agent-factory/logs/frontend-errors.ndjson";
|
||||
const DEFAULT_REPORTER = "sandbox-agent-factory";
|
||||
const MAX_FIELD_LENGTH = 12_000;
|
||||
|
||||
export interface FrontendErrorCollectorRouterOptions {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FrontendErrorCollectorScriptOptions } from "./types.js";
|
||||
|
||||
const DEFAULT_REPORTER = "openhandoff-frontend";
|
||||
const DEFAULT_REPORTER = "sandbox-agent-factory";
|
||||
|
||||
export function createFrontendErrorCollectorScript(
|
||||
options: FrontendErrorCollectorScriptOptions
|
||||
|
|
@ -17,13 +17,13 @@ export function createFrontendErrorCollectorScript(
|
|||
return;
|
||||
}
|
||||
|
||||
if (window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__) {
|
||||
if (window.__FACTORY_FRONTEND_ERROR_COLLECTOR__) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config = ${JSON.stringify(config)};
|
||||
var sharedContext = window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ || {};
|
||||
window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = sharedContext;
|
||||
var sharedContext = window.__FACTORY_FRONTEND_ERROR_CONTEXT__ || {};
|
||||
window.__FACTORY_FRONTEND_ERROR_CONTEXT__ = sharedContext;
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
|
|
@ -124,7 +124,7 @@ export function createFrontendErrorCollectorScript(
|
|||
});
|
||||
}
|
||||
|
||||
window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__ = {
|
||||
window.__FACTORY_FRONTEND_ERROR_COLLECTOR__ = {
|
||||
setContext: function (nextContext) {
|
||||
if (!nextContext || typeof nextContext !== "object") {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Plugin } from "vite";
|
|||
import { createFrontendErrorCollectorRouter, defaultFrontendErrorLogPath } from "./router.js";
|
||||
import { createFrontendErrorCollectorScript } from "./script.js";
|
||||
|
||||
const DEFAULT_MOUNT_PATH = "/__openhandoff/frontend-errors";
|
||||
const DEFAULT_MOUNT_PATH = "/__factory/frontend-errors";
|
||||
const DEFAULT_EVENT_PATH = "/events";
|
||||
|
||||
export interface FrontendErrorCollectorVitePluginOptions {
|
||||
|
|
@ -20,7 +20,7 @@ export function frontendErrorCollectorVitePlugin(
|
|||
): Plugin {
|
||||
const mountPath = normalizePath(options.mountPath ?? DEFAULT_MOUNT_PATH);
|
||||
const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath(process.cwd());
|
||||
const reporter = options.reporter ?? "openhandoff-vite";
|
||||
const reporter = options.reporter ?? "factory-vite";
|
||||
const endpoint = `${mountPath}${DEFAULT_EVENT_PATH}`;
|
||||
|
||||
const router = createFrontendErrorCollectorRouter({
|
||||
|
|
@ -31,7 +31,7 @@ export function frontendErrorCollectorVitePlugin(
|
|||
const listener = getRequestListener(mountApp.fetch);
|
||||
|
||||
return {
|
||||
name: "openhandoff:frontend-error-collector",
|
||||
name: "factory:frontend-error-collector",
|
||||
apply: "serve",
|
||||
transformIndexHtml(html) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ describe("frontend error collector router", () => {
|
|||
describe("frontend error collector script", () => {
|
||||
test("embeds configured endpoint", () => {
|
||||
const script = createFrontendErrorCollectorScript({
|
||||
endpoint: "/__openhandoff/frontend-errors/events",
|
||||
endpoint: "/__factory/frontend-errors/events",
|
||||
});
|
||||
expect(script).toContain("/__openhandoff/frontend-errors/events");
|
||||
expect(script).toContain("/__factory/frontend-errors/events");
|
||||
expect(script).toContain("window.addEventListener(\"error\"");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenHandoff</title>
|
||||
<title>Sandbox Agent Factory</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@openhandoff/frontend",
|
||||
"name": "@sandbox-agent/factory-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -10,9 +10,9 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openhandoff/client": "workspace:*",
|
||||
"@openhandoff/frontend-errors": "workspace:*",
|
||||
"@openhandoff/shared": "workspace:*",
|
||||
"@sandbox-agent/factory-client": "workspace:*",
|
||||
"@sandbox-agent/factory-frontend-errors": "workspace:*",
|
||||
"@sandbox-agent/factory-shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-router": "^1.132.23",
|
||||
"baseui": "^16.1.1",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import { useEffect } from "react";
|
||||
import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { setFrontendErrorContext } from "@sandbox-agent/factory-frontend-errors/client";
|
||||
import { type MockBillingPlanId } from "@sandbox-agent/factory-client";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
MockHostedCheckoutPage,
|
||||
MockOrganizationBillingPage,
|
||||
MockOrganizationImportPage,
|
||||
MockOrganizationSelectorPage,
|
||||
MockOrganizationSettingsPage,
|
||||
MockSignInPage,
|
||||
} from "../components/mock-onboarding";
|
||||
import { defaultWorkspaceId } from "../lib/env";
|
||||
import { handoffWorkbenchClient } from "../lib/workbench";
|
||||
import {
|
||||
activeMockOrganization,
|
||||
activeMockUser,
|
||||
getMockOrganizationById,
|
||||
eligibleOrganizations,
|
||||
useMockAppSnapshot,
|
||||
} from "../lib/mock-app";
|
||||
import { getHandoffWorkbenchClient, resolveRepoRouteHandoffId } from "../lib/workbench";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
|
|
@ -19,13 +36,43 @@ const rootRoute = createRootRoute({
|
|||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: () => (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: defaultWorkspaceId }}
|
||||
replace
|
||||
/>
|
||||
),
|
||||
component: IndexRoute,
|
||||
});
|
||||
|
||||
const signInRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/signin",
|
||||
component: SignInRoute,
|
||||
});
|
||||
|
||||
const organizationsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations",
|
||||
component: OrganizationsRoute,
|
||||
});
|
||||
|
||||
const organizationImportRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/import",
|
||||
component: OrganizationImportRoute,
|
||||
});
|
||||
|
||||
const organizationSettingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/settings",
|
||||
component: OrganizationSettingsRoute,
|
||||
});
|
||||
|
||||
const organizationBillingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/billing",
|
||||
component: OrganizationBillingRoute,
|
||||
});
|
||||
|
||||
const organizationCheckoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/organizations/$organizationId/checkout/$planId",
|
||||
component: OrganizationCheckoutRoute,
|
||||
});
|
||||
|
||||
const workspaceRoute = createRoute({
|
||||
|
|
@ -57,6 +104,12 @@ const repoRoute = createRoute({
|
|||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
signInRoute,
|
||||
organizationsRoute,
|
||||
organizationImportRoute,
|
||||
organizationSettingsRoute,
|
||||
organizationBillingRoute,
|
||||
organizationCheckoutRoute,
|
||||
workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]),
|
||||
]);
|
||||
|
||||
|
|
@ -72,32 +125,141 @@ function WorkspaceLayoutRoute() {
|
|||
return <Outlet />;
|
||||
}
|
||||
|
||||
function IndexRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
}
|
||||
|
||||
function SignInRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
||||
if (snapshot.auth.status === "signed_in") {
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
}
|
||||
|
||||
return <MockSignInPage />;
|
||||
}
|
||||
|
||||
function OrganizationsRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationSelectorPage />;
|
||||
}
|
||||
|
||||
function OrganizationImportRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationImportRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationImportPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationSettingsRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationSettingsRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationSettingsPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationBillingRoute() {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationBillingRoute.useParams().organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
return <MockOrganizationBillingPage organization={organization} />;
|
||||
}
|
||||
|
||||
function OrganizationCheckoutRoute() {
|
||||
const { organizationId, planId } = organizationCheckoutRoute.useParams();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = useGuardedMockOrganization(organizationId);
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
if (!isMockBillingPlanId(planId)) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/billing"
|
||||
params={{ organizationId }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <MockHostedCheckoutPage organization={organization} planId={planId} />;
|
||||
}
|
||||
|
||||
function WorkspaceRoute() {
|
||||
const { workspaceId } = workspaceRoute.useParams();
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId: undefined,
|
||||
});
|
||||
}, [workspaceId]);
|
||||
return <MockLayout workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />;
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function HandoffRoute() {
|
||||
const { workspaceId, handoffId } = handoffRoute.useParams();
|
||||
const { sessionId } = handoffRoute.useSearch();
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
repoId: undefined,
|
||||
});
|
||||
}, [handoffId, workspaceId]);
|
||||
return <MockLayout workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />;
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoRoute() {
|
||||
const { workspaceId, repoId } = repoRoute.useParams();
|
||||
|
||||
return (
|
||||
<MockWorkspaceGate workspaceId={workspaceId}>
|
||||
<RepoRouteInner workspaceId={workspaceId} repoId={repoId} />
|
||||
</MockWorkspaceGate>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const client = getHandoffWorkbenchClient(workspaceId);
|
||||
const snapshot = useSyncExternalStore(
|
||||
client.subscribe.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
|
|
@ -105,9 +267,8 @@ function RepoRoute() {
|
|||
repoId,
|
||||
});
|
||||
}, [repoId, workspaceId]);
|
||||
const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find(
|
||||
(handoff) => handoff.repoId === repoId,
|
||||
)?.id;
|
||||
|
||||
const activeHandoffId = resolveRepoRouteHandoffId(snapshot, repoId);
|
||||
if (!activeHandoffId) {
|
||||
return (
|
||||
<Navigate
|
||||
|
|
@ -130,6 +291,174 @@ function RepoRoute() {
|
|||
);
|
||||
}
|
||||
|
||||
function WorkspaceView({
|
||||
workspaceId,
|
||||
selectedHandoffId,
|
||||
selectedSessionId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
selectedHandoffId: string | null;
|
||||
selectedSessionId: string | null;
|
||||
}) {
|
||||
const client = getHandoffWorkbenchClient(workspaceId);
|
||||
const navigate = useNavigate();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
handoffId: selectedHandoffId ?? undefined,
|
||||
repoId: undefined,
|
||||
});
|
||||
}, [selectedHandoffId, workspaceId]);
|
||||
|
||||
return (
|
||||
<MockLayout
|
||||
client={client}
|
||||
workspaceId={workspaceId}
|
||||
selectedHandoffId={selectedHandoffId}
|
||||
selectedSessionId={selectedSessionId}
|
||||
sidebarTitle={organization?.settings.displayName}
|
||||
sidebarSubtitle={
|
||||
organization
|
||||
? `${organization.billing.planId} plan · ${organization.seatAssignments.length}/${organization.billing.seatsIncluded} seats`
|
||||
: undefined
|
||||
}
|
||||
sidebarActions={
|
||||
organization
|
||||
? [
|
||||
{
|
||||
label: "Switch org",
|
||||
onClick: () => void navigate({ to: "/organizations" }),
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
onClick: () =>
|
||||
void navigate({
|
||||
to: "/organizations/$organizationId/settings",
|
||||
params: { organizationId: organization.id },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Billing",
|
||||
onClick: () =>
|
||||
void navigate({
|
||||
to: "/organizations/$organizationId/billing",
|
||||
params: { organizationId: organization.id },
|
||||
}),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MockWorkspaceGate({
|
||||
workspaceId,
|
||||
children,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
|
||||
if (snapshot.auth.status === "signed_out") {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
const activeOrganization = activeMockOrganization(snapshot);
|
||||
const workspaceOrganization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
|
||||
|
||||
if (!workspaceOrganization) {
|
||||
return <NavigateToMockHome snapshot={snapshot} replace />;
|
||||
}
|
||||
|
||||
if (!activeOrganization || activeOrganization.id !== workspaceOrganization.id) {
|
||||
return <Navigate to="/organizations" replace />;
|
||||
}
|
||||
|
||||
if (workspaceOrganization.repoImportStatus !== "ready") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/import"
|
||||
params={{ organizationId: workspaceOrganization.id }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function NavigateToMockHome({
|
||||
snapshot,
|
||||
replace = false,
|
||||
}: {
|
||||
snapshot: ReturnType<typeof useMockAppSnapshot>;
|
||||
replace?: boolean;
|
||||
}) {
|
||||
const activeOrganization = activeMockOrganization(snapshot);
|
||||
const organizations = eligibleOrganizations(snapshot);
|
||||
const targetOrganization =
|
||||
activeOrganization ?? (organizations.length === 1 ? organizations[0] ?? null : null);
|
||||
|
||||
if (snapshot.auth.status === "signed_out" || !activeMockUser(snapshot)) {
|
||||
return <Navigate to="/signin" replace={replace} />;
|
||||
}
|
||||
|
||||
if (!targetOrganization) {
|
||||
return snapshot.users.length === 0 ? (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: defaultWorkspaceId }}
|
||||
replace={replace}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/organizations" replace={replace} />
|
||||
);
|
||||
}
|
||||
|
||||
if (targetOrganization.repoImportStatus !== "ready") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/organizations/$organizationId/import"
|
||||
params={{ organizationId: targetOrganization.id }}
|
||||
replace={replace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId: targetOrganization.workspaceId }}
|
||||
replace={replace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useGuardedMockOrganization(organizationId: string) {
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const user = activeMockUser(snapshot);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const organization = getMockOrganizationById(snapshot, organizationId);
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.eligibleOrganizationIds.includes(organization.id) ? organization : null;
|
||||
}
|
||||
|
||||
function isMockBillingPlanId(planId: string): planId is MockBillingPlanId {
|
||||
return planId === "free" || planId === "team" || planId === "enterprise";
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { DiffContent } from "./mock-layout/diff-content";
|
||||
|
|
@ -8,7 +9,8 @@ import { RightSidebar } from "./mock-layout/right-sidebar";
|
|||
import { Sidebar } from "./mock-layout/sidebar";
|
||||
import { TabStrip } from "./mock-layout/tab-strip";
|
||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import { RightSidebarSkeleton, SidebarSkeleton, TranscriptSkeleton } from "./mock-layout/skeleton";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, PanelHeaderBar, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import {
|
||||
buildDisplayMessages,
|
||||
buildHistoryEvents,
|
||||
|
|
@ -22,7 +24,6 @@ import {
|
|||
type Message,
|
||||
type ModelId,
|
||||
} from "./mock-layout/view-model";
|
||||
import { handoffWorkbenchClient } from "../lib/workbench";
|
||||
|
||||
function firstAgentTabId(handoff: Handoff): string | null {
|
||||
return handoff.tabs[0]?.id ?? null;
|
||||
|
|
@ -63,6 +64,7 @@ function sanitizeActiveTabId(
|
|||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
client,
|
||||
handoff,
|
||||
activeTabId,
|
||||
lastAgentTabId,
|
||||
|
|
@ -72,6 +74,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSetLastAgentTabId,
|
||||
onSetOpenDiffs,
|
||||
}: {
|
||||
client: HandoffWorkbenchClient;
|
||||
handoff: Handoff;
|
||||
activeTabId: string | null;
|
||||
lastAgentTabId: string | null;
|
||||
|
|
@ -172,12 +175,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.setSessionUnread({
|
||||
void client.setSessionUnread({
|
||||
handoffId: handoff.id,
|
||||
tabId: activeAgentTab.id,
|
||||
unread: false,
|
||||
});
|
||||
}, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]);
|
||||
}, [activeAgentTab?.id, activeAgentTab?.unread, client, handoff.id]);
|
||||
|
||||
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
|
||||
setEditingField(field);
|
||||
|
|
@ -197,13 +200,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
}
|
||||
|
||||
if (field === "title") {
|
||||
void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value });
|
||||
void client.renameHandoff({ handoffId: handoff.id, value });
|
||||
} else {
|
||||
void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value });
|
||||
void client.renameBranch({ handoffId: handoff.id, value });
|
||||
}
|
||||
setEditingField(null);
|
||||
},
|
||||
[editValue, handoff.id],
|
||||
[client, editValue, handoff.id],
|
||||
);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
|
|
@ -212,14 +215,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.updateDraft({
|
||||
void client.updateDraft({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
text: nextText,
|
||||
attachments: nextAttachments,
|
||||
});
|
||||
},
|
||||
[handoff.id, promptTab],
|
||||
[client, handoff.id, promptTab],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
|
|
@ -230,24 +233,24 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
|
||||
onSetActiveTabId(promptTab.id);
|
||||
onSetLastAgentTabId(promptTab.id);
|
||||
void handoffWorkbenchClient.sendMessage({
|
||||
void client.sendMessage({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
}, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
|
||||
}, [attachments, client, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
|
||||
|
||||
const stopAgent = useCallback(() => {
|
||||
if (!promptTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.stopAgent({
|
||||
void client.stopAgent({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
});
|
||||
}, [handoff.id, promptTab]);
|
||||
}, [client, handoff.id, promptTab]);
|
||||
|
||||
const switchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
|
|
@ -257,7 +260,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSetLastAgentTabId(tabId);
|
||||
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (tab?.unread) {
|
||||
void handoffWorkbenchClient.setSessionUnread({
|
||||
void client.setSessionUnread({
|
||||
handoffId: handoff.id,
|
||||
tabId,
|
||||
unread: false,
|
||||
|
|
@ -266,14 +269,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSyncRouteSession(handoff.id, tabId);
|
||||
}
|
||||
},
|
||||
[handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
[client, handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
);
|
||||
|
||||
const setTabUnread = useCallback(
|
||||
(tabId: string, unread: boolean) => {
|
||||
void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread });
|
||||
void client.setSessionUnread({ handoffId: handoff.id, tabId, unread });
|
||||
},
|
||||
[handoff.id],
|
||||
[client, handoff.id],
|
||||
);
|
||||
|
||||
const startRenamingTab = useCallback(
|
||||
|
|
@ -305,13 +308,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.renameSession({
|
||||
void client.renameSession({
|
||||
handoffId: handoff.id,
|
||||
tabId: editingSessionTabId,
|
||||
title: trimmedName,
|
||||
});
|
||||
cancelTabRename();
|
||||
}, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]);
|
||||
}, [cancelTabRename, client, editingSessionName, editingSessionTabId, handoff.id]);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
|
|
@ -326,9 +329,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
}
|
||||
|
||||
onSyncRouteSession(handoff.id, nextTabId);
|
||||
void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId });
|
||||
void client.closeTab({ handoffId: handoff.id, tabId });
|
||||
},
|
||||
[activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
[activeTabId, client, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
|
||||
);
|
||||
|
||||
const closeDiffTab = useCallback(
|
||||
|
|
@ -346,12 +349,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
|
||||
const addTab = useCallback(() => {
|
||||
void (async () => {
|
||||
const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id });
|
||||
const { tabId } = await client.addTab({ handoffId: handoff.id });
|
||||
onSetLastAgentTabId(tabId);
|
||||
onSetActiveTabId(tabId);
|
||||
onSyncRouteSession(handoff.id, tabId);
|
||||
})();
|
||||
}, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
|
||||
}, [client, handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
|
||||
|
||||
const changeModel = useCallback(
|
||||
(model: ModelId) => {
|
||||
|
|
@ -359,13 +362,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`);
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.changeModel({
|
||||
void client.changeModel({
|
||||
handoffId: handoff.id,
|
||||
tabId: promptTab.id,
|
||||
model,
|
||||
});
|
||||
},
|
||||
[handoff.id, promptTab],
|
||||
[client, handoff.id, promptTab],
|
||||
);
|
||||
|
||||
const addAttachment = useCallback(
|
||||
|
|
@ -551,17 +554,32 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
});
|
||||
|
||||
interface MockLayoutProps {
|
||||
client: HandoffWorkbenchClient;
|
||||
workspaceId: string;
|
||||
selectedHandoffId?: string | null;
|
||||
selectedSessionId?: string | null;
|
||||
sidebarTitle?: string;
|
||||
sidebarSubtitle?: string;
|
||||
sidebarActions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) {
|
||||
export function MockLayout({
|
||||
client,
|
||||
workspaceId,
|
||||
selectedHandoffId,
|
||||
selectedSessionId,
|
||||
sidebarTitle,
|
||||
sidebarSubtitle,
|
||||
sidebarActions,
|
||||
}: MockLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const viewModel = useSyncExternalStore(
|
||||
handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient),
|
||||
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
|
||||
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
|
||||
client.subscribe.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
client.getSnapshot.bind(client),
|
||||
);
|
||||
const handoffs = viewModel.handoffs ?? [];
|
||||
const projects = viewModel.projects ?? [];
|
||||
|
|
@ -661,19 +679,10 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
throw new Error("Cannot create a handoff without an available repo");
|
||||
}
|
||||
|
||||
const task = window.prompt("Describe the handoff task", "Investigate and implement the requested change");
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = window.prompt("Optional handoff title", "")?.trim() || undefined;
|
||||
const branch = window.prompt("Optional branch name", "")?.trim() || undefined;
|
||||
const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({
|
||||
const { handoffId, tabId } = await client.createHandoff({
|
||||
repoId,
|
||||
task,
|
||||
task: "",
|
||||
model: "gpt-4o",
|
||||
...(title ? { title } : {}),
|
||||
...(branch ? { branch } : {}),
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/handoffs/$handoffId",
|
||||
|
|
@ -684,7 +693,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
}, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]);
|
||||
}, [activeHandoff?.repoId, client, navigate, viewModel.repos, workspaceId]);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -726,8 +735,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
);
|
||||
|
||||
const markHandoffUnread = useCallback((id: string) => {
|
||||
void handoffWorkbenchClient.markHandoffUnread({ handoffId: id });
|
||||
}, []);
|
||||
void client.markHandoffUnread({ handoffId: id });
|
||||
}, [client]);
|
||||
|
||||
const renameHandoff = useCallback(
|
||||
(id: string) => {
|
||||
|
|
@ -746,9 +755,9 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle });
|
||||
void client.renameHandoff({ handoffId: id, value: trimmedTitle });
|
||||
},
|
||||
[handoffs],
|
||||
[client, handoffs],
|
||||
);
|
||||
|
||||
const renameBranch = useCallback(
|
||||
|
|
@ -768,24 +777,31 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
return;
|
||||
}
|
||||
|
||||
void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch });
|
||||
void client.renameBranch({ handoffId: id, value: trimmedBranch });
|
||||
},
|
||||
[handoffs],
|
||||
[client, handoffs],
|
||||
);
|
||||
|
||||
const archiveHandoff = useCallback(() => {
|
||||
if (!activeHandoff) {
|
||||
throw new Error("Cannot archive without an active handoff");
|
||||
}
|
||||
void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff]);
|
||||
void client.archiveHandoff({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff, client]);
|
||||
|
||||
const publishPr = useCallback(() => {
|
||||
if (!activeHandoff) {
|
||||
throw new Error("Cannot publish PR without an active handoff");
|
||||
}
|
||||
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff]);
|
||||
void client.publishPr({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff, client]);
|
||||
|
||||
const pushHandoff = useCallback(() => {
|
||||
if (!activeHandoff) {
|
||||
throw new Error("Cannot push without an active handoff");
|
||||
}
|
||||
void client.pushHandoff({ handoffId: activeHandoff.id });
|
||||
}, [activeHandoff, client]);
|
||||
|
||||
const revertFile = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -804,20 +820,54 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
: current[activeHandoff.id] ?? null,
|
||||
}));
|
||||
|
||||
void handoffWorkbenchClient.revertFile({
|
||||
void client.revertFile({
|
||||
handoffId: activeHandoff.id,
|
||||
path,
|
||||
});
|
||||
},
|
||||
[activeHandoff, lastAgentTabIdByHandoff],
|
||||
[activeHandoff, client, lastAgentTabIdByHandoff],
|
||||
);
|
||||
|
||||
// Show full-page skeleton while the client snapshot is still empty (initial load)
|
||||
const isInitialLoad = handoffs.length === 0 && projects.length === 0 && viewModel.repos.length === 0;
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<Shell>
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<div style={{ flex: 1 }} />
|
||||
</PanelHeaderBar>
|
||||
<ScrollBody>
|
||||
<SidebarSkeleton />
|
||||
</ScrollBody>
|
||||
</SPanel>
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<div style={{ flex: 1 }} />
|
||||
</PanelHeaderBar>
|
||||
<TranscriptSkeleton />
|
||||
</SPanel>
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<div style={{ flex: 1 }} />
|
||||
</PanelHeaderBar>
|
||||
<RightSidebarSkeleton />
|
||||
</SPanel>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeHandoff) {
|
||||
return (
|
||||
<Shell>
|
||||
<Sidebar
|
||||
workspaceId={workspaceId}
|
||||
repoCount={viewModel.repos.length}
|
||||
projects={projects}
|
||||
activeId=""
|
||||
title={sidebarTitle}
|
||||
subtitle={sidebarSubtitle}
|
||||
actions={sidebarActions}
|
||||
onSelect={selectHandoff}
|
||||
onCreate={createHandoff}
|
||||
onMarkUnread={markHandoffUnread}
|
||||
|
|
@ -879,8 +929,13 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
return (
|
||||
<Shell>
|
||||
<Sidebar
|
||||
workspaceId={workspaceId}
|
||||
repoCount={viewModel.repos.length}
|
||||
projects={projects}
|
||||
activeId={activeHandoff.id}
|
||||
title={sidebarTitle}
|
||||
subtitle={sidebarSubtitle}
|
||||
actions={sidebarActions}
|
||||
onSelect={selectHandoff}
|
||||
onCreate={createHandoff}
|
||||
onMarkUnread={markHandoffUnread}
|
||||
|
|
@ -888,6 +943,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
onRenameBranch={renameBranch}
|
||||
/>
|
||||
<TranscriptPanel
|
||||
client={client}
|
||||
handoff={activeHandoff}
|
||||
activeTabId={activeTabId}
|
||||
lastAgentTabId={lastAgentTabId}
|
||||
|
|
@ -908,6 +964,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
activeTabId={activeTabId}
|
||||
onOpenDiff={openDiffTab}
|
||||
onArchive={archiveHandoff}
|
||||
onPush={pushHandoff}
|
||||
onRevertFile={revertFile}
|
||||
onPublishPr={publishPr}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LabelSmall, LabelXSmall } from "baseui/typography";
|
|||
import { Copy } from "lucide-react";
|
||||
|
||||
import { HistoryMinimap } from "./history-minimap";
|
||||
import { SkeletonBlock, SkeletonLine } from "./skeleton";
|
||||
import { SpinnerDot } from "./ui";
|
||||
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
|
||||
|
||||
|
|
@ -45,21 +46,40 @@ export const MessageList = memo(function MessageList({
|
|||
})}
|
||||
>
|
||||
{tab && messages.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
minHeight: "200px",
|
||||
gap: "8px",
|
||||
})}
|
||||
>
|
||||
<LabelSmall color={theme.colors.contentTertiary}>
|
||||
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
tab.created && tab.status === "running" ? (
|
||||
/* New tab that's loading — show message skeleton */
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: "flex", justifyContent: "flex-end" })}>
|
||||
<SkeletonBlock width={200} height={44} borderRadius={16} />
|
||||
</div>
|
||||
<div className={css({ display: "flex", justifyContent: "flex-start" })}>
|
||||
<SkeletonBlock width={280} height={64} borderRadius={16} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
minHeight: "200px",
|
||||
gap: "8px",
|
||||
})}
|
||||
>
|
||||
<LabelSmall color={theme.colors.contentTertiary}>
|
||||
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||
</LabelSmall>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{messages.map((message) => {
|
||||
const isUser = message.sender === "client";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,49 @@ import {
|
|||
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
|
||||
|
||||
const StatusCard = memo(function StatusCard({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const [css, theme] = useStyletron();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: "10px 12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
border: `1px solid ${theme.colors.borderOpaque}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
})}
|
||||
>
|
||||
<LabelSmall color={theme.colors.contentTertiary} $style={{ fontSize: "10px", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
{label}
|
||||
</LabelSmall>
|
||||
<div
|
||||
className={css({
|
||||
color: theme.colors.contentPrimary,
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
fontFamily: mono ? '"IBM Plex Mono", monospace' : undefined,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const FileTree = memo(function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
|
|
@ -106,6 +149,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
activeTabId,
|
||||
onOpenDiff,
|
||||
onArchive,
|
||||
onPush,
|
||||
onRevertFile,
|
||||
onPublishPr,
|
||||
}: {
|
||||
|
|
@ -113,6 +157,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
activeTabId: string | null;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onArchive: () => void;
|
||||
onPush: () => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
onPublishPr: () => void;
|
||||
}) {
|
||||
|
|
@ -121,7 +166,12 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
const contextMenu = useContextMenu();
|
||||
const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]);
|
||||
const isTerminal = handoff.status === "archived";
|
||||
const canPush = !isTerminal && Boolean(handoff.branch);
|
||||
const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null;
|
||||
const pullRequestStatus =
|
||||
handoff.pullRequest == null
|
||||
? "Not published"
|
||||
: `#${handoff.pullRequest.number} ${handoff.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
|
||||
|
||||
const copyFilePath = useCallback(async (path: string) => {
|
||||
try {
|
||||
|
|
@ -183,6 +233,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
{pullRequestUrl ? "Open PR" : "Publish PR"}
|
||||
</button>
|
||||
<button
|
||||
onClick={canPush ? onPush : undefined}
|
||||
className={css({
|
||||
all: "unset",
|
||||
display: "flex",
|
||||
|
|
@ -192,8 +243,9 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 500,
|
||||
color: "#e4e4e7",
|
||||
cursor: "pointer",
|
||||
color: canPush ? "#e4e4e7" : theme.colors.contentTertiary,
|
||||
cursor: canPush ? "pointer" : "not-allowed",
|
||||
opacity: canPush ? 1 : 0.5,
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
|
||||
})}
|
||||
|
|
@ -303,6 +355,10 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
</div>
|
||||
|
||||
<ScrollBody>
|
||||
<div className={css({ padding: "12px 14px 0", display: "grid", gap: "8px" })}>
|
||||
<StatusCard label="Branch" value={handoff.branch ?? "Not created"} mono />
|
||||
<StatusCard label="Pull Request" value={pullRequestStatus} />
|
||||
</div>
|
||||
{rightTab === "changes" ? (
|
||||
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
|
||||
{handoff.fileChanges.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useStyletron } from "baseui";
|
|||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
|
||||
|
||||
import { SidebarSkeleton } from "./skeleton";
|
||||
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
|
||||
import {
|
||||
ContextMenuOverlay,
|
||||
|
|
@ -14,16 +15,29 @@ import {
|
|||
} from "./ui";
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
workspaceId,
|
||||
repoCount,
|
||||
projects,
|
||||
activeId,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onMarkUnread,
|
||||
onRenameHandoff,
|
||||
onRenameBranch,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
repoCount: number;
|
||||
projects: ProjectSection[];
|
||||
activeId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
|
|
@ -37,11 +51,17 @@ export const Sidebar = memo(function Sidebar({
|
|||
return (
|
||||
<SPanel>
|
||||
<PanelHeaderBar>
|
||||
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, flex: 1, fontSize: "13px" }}>
|
||||
Handoffs
|
||||
</LabelSmall>
|
||||
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, fontSize: "13px" }}>
|
||||
{title ?? workspaceId}
|
||||
</LabelSmall>
|
||||
<LabelXSmall color={theme.colors.contentTertiary}>
|
||||
{subtitle ?? `${repoCount} ${repoCount === 1 ? "repo" : "repos"}`}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
aria-label="Create handoff"
|
||||
className={css({
|
||||
all: "unset",
|
||||
width: "24px",
|
||||
|
|
@ -60,7 +80,44 @@ export const Sidebar = memo(function Sidebar({
|
|||
<Plus size={14} />
|
||||
</button>
|
||||
</PanelHeaderBar>
|
||||
{actions && actions.length > 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
padding: "10px 14px 0",
|
||||
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
})}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className={css({
|
||||
border: `1px solid ${theme.colors.borderOpaque}`,
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.04)",
|
||||
color: theme.colors.contentPrimary,
|
||||
cursor: "pointer",
|
||||
padding: "6px 10px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
marginBottom: "10px",
|
||||
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
|
||||
})}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollBody>
|
||||
{projects.length === 0 ? (
|
||||
<SidebarSkeleton />
|
||||
) : (
|
||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
{projects.map((project) => {
|
||||
const visibleCount = expandedProjects[project.id] ? project.handoffs.length : Math.min(project.handoffs.length, 5);
|
||||
|
|
@ -92,10 +149,22 @@ export const Sidebar = memo(function Sidebar({
|
|||
{project.label}
|
||||
</LabelSmall>
|
||||
<LabelXSmall color={theme.colors.contentTertiary}>
|
||||
{formatRelativeAge(project.updatedAtMs)}
|
||||
{project.updatedAtMs > 0 ? formatRelativeAge(project.updatedAtMs) : "No handoffs"}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
|
||||
{project.handoffs.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: "0 12px 10px 34px",
|
||||
color: theme.colors.contentTertiary,
|
||||
fontSize: "12px",
|
||||
})}
|
||||
>
|
||||
No handoffs yet
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{project.handoffs.slice(0, visibleCount).map((handoff) => {
|
||||
const isActive = handoff.id === activeId;
|
||||
const isDim = handoff.status === "archived";
|
||||
|
|
@ -218,6 +287,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollBody>
|
||||
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
|
||||
</SPanel>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { memo } from "react";
|
||||
|
||||
export const SkeletonLine = memo(function SkeletonLine({
|
||||
width = "100%",
|
||||
height = 12,
|
||||
borderRadius = 4,
|
||||
style,
|
||||
}: {
|
||||
width?: string | number;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
background: "rgba(255, 255, 255, 0.06)",
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.04) 50%, rgba(255,255,255,0) 100%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "hf-shimmer 1.5s ease-in-out infinite",
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const SkeletonCircle = memo(function SkeletonCircle({
|
||||
size = 14,
|
||||
style,
|
||||
}: {
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return <SkeletonLine width={size} height={size} borderRadius={size} style={style} />;
|
||||
});
|
||||
|
||||
export const SkeletonBlock = memo(function SkeletonBlock({
|
||||
width = "100%",
|
||||
height = 60,
|
||||
borderRadius = 8,
|
||||
style,
|
||||
}: {
|
||||
width?: string | number;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return <SkeletonLine width={width} height={height} borderRadius={borderRadius} style={style} />;
|
||||
});
|
||||
|
||||
/** Sidebar skeleton: header + list of handoff placeholders */
|
||||
export const SidebarSkeleton = memo(function SidebarSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{/* Project header skeleton */}
|
||||
<div style={{ padding: "10px 8px 4px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<SkeletonLine width="40%" height={10} />
|
||||
<SkeletonLine width={48} height={10} />
|
||||
</div>
|
||||
{/* Handoff item skeletons */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<SkeletonCircle size={14} />
|
||||
<SkeletonLine width={`${65 - i * 10}%`} height={13} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", paddingLeft: "22px" }}>
|
||||
<SkeletonLine width="30%" height={10} />
|
||||
<SkeletonLine width={32} height={10} style={{ marginLeft: "auto" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Transcript area skeleton: tab strip + message bubbles */
|
||||
export const TranscriptSkeleton = memo(function TranscriptSkeleton() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
{/* Tab strip skeleton */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
height: "41px",
|
||||
minHeight: "41px",
|
||||
padding: "0 14px",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<SkeletonCircle size={8} />
|
||||
<SkeletonLine width={64} height={11} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Message skeletons */}
|
||||
<div style={{ padding: "16px 220px 16px 44px", display: "flex", flexDirection: "column", gap: "12px", flex: 1 }}>
|
||||
{/* User message */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div style={{ maxWidth: "60%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-end" }}>
|
||||
<SkeletonBlock width={240} height={48} borderRadius={16} />
|
||||
<SkeletonLine width={60} height={9} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Agent message */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-start" }}>
|
||||
<div style={{ maxWidth: "70%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-start" }}>
|
||||
<SkeletonBlock width={320} height={72} borderRadius={16} />
|
||||
<SkeletonLine width={100} height={9} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Another user message */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div style={{ maxWidth: "60%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-end" }}>
|
||||
<SkeletonBlock width={180} height={40} borderRadius={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Right sidebar skeleton: status cards + file list */
|
||||
export const RightSidebarSkeleton = memo(function RightSidebarSkeleton() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
{/* Tab bar skeleton */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
height: "41px",
|
||||
minHeight: "41px",
|
||||
padding: "0 16px",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<SkeletonLine width={56} height={11} />
|
||||
<SkeletonLine width={48} height={11} />
|
||||
</div>
|
||||
{/* Status cards */}
|
||||
<div style={{ padding: "12px 14px 0", display: "grid", gap: "8px" }}>
|
||||
<SkeletonBlock height={52} />
|
||||
<SkeletonBlock height={52} />
|
||||
</div>
|
||||
{/* File changes */}
|
||||
<div style={{ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px" }}>
|
||||
<SkeletonCircle size={14} />
|
||||
<SkeletonLine width={`${60 - i * 12}%`} height={12} />
|
||||
<div style={{ display: "flex", gap: "6px", marginLeft: "auto" }}>
|
||||
<SkeletonLine width={24} height={11} />
|
||||
<SkeletonLine width={24} height={11} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkbenchAgentTab } from "@openhandoff/shared";
|
||||
import type { WorkbenchAgentTab } from "@sandbox-agent/factory-shared";
|
||||
import { buildDisplayMessages } from "./view-model";
|
||||
|
||||
function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
WorkbenchParsedDiffLine as ParsedDiffLine,
|
||||
WorkbenchProjectSection as ProjectSection,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { extractEventText } from "../../features/sessions/model";
|
||||
|
||||
export type { ProjectSection };
|
||||
|
|
|
|||
1034
factory/packages/frontend/src/components/mock-onboarding.tsx
Normal file
1034
factory/packages/frontend/src/components/mock-onboarding.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@openhandoff/shared";
|
||||
import { groupHandoffStatus, type SandboxSessionEventRecord } from "@openhandoff/client";
|
||||
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared";
|
||||
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
|
||||
import { groupHandoffStatus } from "@sandbox-agent/factory-client/view-model";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "baseui/button";
|
||||
|
|
|
|||
7
factory/packages/frontend/src/factory-client-view-model.d.ts
vendored
Normal file
7
factory/packages/frontend/src/factory-client-view-model.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
declare module "@sandbox-agent/factory-client/view-model" {
|
||||
export {
|
||||
HANDOFF_STATUS_GROUPS,
|
||||
groupHandoffStatus,
|
||||
} from "@sandbox-agent/factory-client";
|
||||
export type { HandoffStatusGroup } from "@sandbox-agent/factory-client";
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@openhandoff/shared";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { formatDiffStat, groupHandoffsByRepo } from "./model";
|
||||
|
||||
const base: HandoffRecord = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { HandoffRecord } from "@openhandoff/shared";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export interface RepoGroup {
|
||||
repoId: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SandboxSessionRecord } from "@openhandoff/client";
|
||||
import type { SandboxSessionRecord } from "@sandbox-agent/factory-client";
|
||||
import { buildTranscript, extractEventText, resolveSessionSelection } from "./model";
|
||||
|
||||
describe("extractEventText", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SandboxSessionEventRecord } from "@openhandoff/client";
|
||||
import type { SandboxSessionRecord } from "@openhandoff/client";
|
||||
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
|
||||
import type { SandboxSessionRecord } from "@sandbox-agent/factory-client";
|
||||
|
||||
function fromPromptArray(value: unknown): string | null {
|
||||
if (!Array.isArray(value)) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createBackendClient } from "@openhandoff/client";
|
||||
import { createBackendClient } from "@sandbox-agent/factory-client/backend";
|
||||
import { backendEndpoint, defaultWorkspaceId } from "./env";
|
||||
|
||||
export const backendClient = createBackendClient({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ function resolveDefaultBackendEndpoint(): string {
|
|||
}
|
||||
|
||||
type FrontendImportMetaEnv = ImportMetaEnv & {
|
||||
OPENHANDOFF_FRONTEND_CLIENT_MODE?: string;
|
||||
FACTORY_FRONTEND_CLIENT_MODE?: string;
|
||||
};
|
||||
|
||||
const frontendEnv = import.meta.env as FrontendImportMetaEnv;
|
||||
|
|
@ -17,7 +17,7 @@ export const backendEndpoint =
|
|||
export const defaultWorkspaceId = import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
|
||||
|
||||
function resolveFrontendClientMode(): "mock" | "remote" {
|
||||
const raw = frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
|
||||
const raw = frontendEnv.FACTORY_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
|
||||
if (raw === "mock") {
|
||||
return "mock";
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ function resolveFrontendClientMode(): "mock" | "remote" {
|
|||
return "remote";
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported OPENHANDOFF_FRONTEND_CLIENT_MODE value "${frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
|
||||
`Unsupported FACTORY_FRONTEND_CLIENT_MODE value "${frontendEnv.FACTORY_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
40
factory/packages/frontend/src/lib/mock-app.ts
Normal file
40
factory/packages/frontend/src/lib/mock-app.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
createFactoryAppClient,
|
||||
currentFactoryOrganization,
|
||||
currentFactoryUser,
|
||||
eligibleFactoryOrganizations,
|
||||
type FactoryAppClient,
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import type { FactoryAppSnapshot, FactoryOrganization } from "@sandbox-agent/factory-shared";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const appClient: FactoryAppClient = createFactoryAppClient({
|
||||
mode: frontendClientMode,
|
||||
backend: frontendClientMode === "remote" ? backendClient : undefined,
|
||||
});
|
||||
|
||||
export function useMockAppSnapshot(): FactoryAppSnapshot {
|
||||
return useSyncExternalStore(
|
||||
appClient.subscribe.bind(appClient),
|
||||
appClient.getSnapshot.bind(appClient),
|
||||
appClient.getSnapshot.bind(appClient),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMockAppClient(): FactoryAppClient {
|
||||
return appClient;
|
||||
}
|
||||
|
||||
export const activeMockUser = currentFactoryUser;
|
||||
export const activeMockOrganization = currentFactoryOrganization;
|
||||
export const eligibleOrganizations = eligibleFactoryOrganizations;
|
||||
|
||||
export function getMockOrganizationById(
|
||||
snapshot: FactoryAppSnapshot,
|
||||
organizationId: string,
|
||||
): FactoryOrganization | null {
|
||||
return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null;
|
||||
}
|
||||
|
||||
8
factory/packages/frontend/src/lib/workbench-routing.ts
Normal file
8
factory/packages/frontend/src/lib/workbench-routing.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export function resolveRepoRouteHandoffId(
|
||||
snapshot: HandoffWorkbenchSnapshot,
|
||||
repoId: string,
|
||||
): string | null {
|
||||
return snapshot.handoffs.find((handoff) => handoff.repoId === repoId)?.id ?? null;
|
||||
}
|
||||
11
factory/packages/frontend/src/lib/workbench-runtime.mock.ts
Normal file
11
factory/packages/frontend/src/lib/workbench-runtime.mock.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import {
|
||||
createHandoffWorkbenchClient,
|
||||
type HandoffWorkbenchClient,
|
||||
} from "@sandbox-agent/factory-client/workbench";
|
||||
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
return createHandoffWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import {
|
||||
createHandoffWorkbenchClient,
|
||||
type HandoffWorkbenchClient,
|
||||
} from "@sandbox-agent/factory-client/workbench";
|
||||
import { backendClient } from "./backend";
|
||||
|
||||
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
|
||||
return createHandoffWorkbenchClient({
|
||||
mode: "remote",
|
||||
backend: backendClient,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue