Rename Factory to Foundry

This commit is contained in:
Nathan Flurry 2026-03-10 22:01:39 -07:00
parent 0a8fda040b
commit 324de36577
256 changed files with 605 additions and 603 deletions

View file

@ -0,0 +1,54 @@
{
"name": "@sandbox-agent/foundry-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 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",
"test:e2e:workbench": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workbench-e2e.test.ts",
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts"
},
"dependencies": {
"@sandbox-agent/foundry-shared": "workspace:*",
"rivetkit": "2.1.6"
},
"devDependencies": {
"tsup": "^8.5.0"
}
}

View file

@ -0,0 +1,65 @@
import type {
FoundryAppSnapshot,
FoundryBillingPlanId,
FoundryOrganization,
FoundryUser,
UpdateFoundryOrganizationProfileInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getMockFoundryAppClient } from "./mock-app.js";
import { createRemoteFoundryAppClient } from "./remote/app-client.js";
export interface FoundryAppClient {
getSnapshot(): FoundryAppSnapshot;
subscribe(listener: () => void): () => void;
signInWithGithub(userId?: string): Promise<void>;
signOut(): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
recordSeatUsage(workspaceId: string): Promise<void>;
}
export interface CreateFoundryAppClientOptions {
mode: "mock" | "remote";
backend?: BackendClient;
}
export function createFoundryAppClient(options: CreateFoundryAppClientOptions): FoundryAppClient {
if (options.mode === "mock") {
return getMockFoundryAppClient() as unknown as FoundryAppClient;
}
if (!options.backend) {
throw new Error("Remote app client requires a backend client");
}
return createRemoteFoundryAppClient({ backend: options.backend });
}
export function currentFoundryUser(snapshot: FoundryAppSnapshot): FoundryUser | null {
if (!snapshot.auth.currentUserId) {
return null;
}
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
}
export function currentFoundryOrganization(snapshot: FoundryAppSnapshot): FoundryOrganization | null {
if (!snapshot.activeOrganizationId) {
return null;
}
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
}
export function eligibleFoundryOrganizations(snapshot: FoundryAppSnapshot): FoundryOrganization[] {
const user = currentFoundryUser(snapshot);
if (!user) {
return [];
}
const eligible = new Set(user.eligibleOrganizationIds);
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
}

View file

@ -0,0 +1,998 @@
import { createClient } from "rivetkit/client";
import type {
AgentType,
AddRepoInput,
AppConfig,
FoundryAppSnapshot,
FoundryBillingPlanId,
CreateTaskInput,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ProviderId,
RepoOverview,
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
UpdateFoundryOrganizationProfileInput,
SwitchResult
} from "@sandbox-agent/foundry-shared";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill";
type RivetMetadataResponse = {
runtime?: string;
actorNames?: Record<string, unknown>;
clientEndpoint?: string;
clientNamespace?: string;
clientToken?: string;
};
export interface SandboxSessionRecord {
id: string;
agent: string;
agentSessionId: string;
lastConnectionId: string;
createdAt: number;
destroyedAt?: number;
status?: "running" | "idle" | "error";
}
export interface SandboxSessionEventRecord {
id: string;
eventIndex: number;
sessionId: string;
createdAt: number;
connectionId: string;
sender: "client" | "agent";
payload: unknown;
}
interface WorkspaceHandle {
addRepo(input: AddRepoInput): Promise<RepoRecord>;
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(input: { workspaceId: string; repoId?: string }): Promise<TaskSummary[]>;
getRepoOverview(input: { workspaceId: string; repoId: string }): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
history(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(taskId: string): Promise<SwitchResult>;
getTask(input: { workspaceId: string; taskId: string }): Promise<TaskRecord>;
attachTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
syncTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
mergeTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
archiveTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>;
getWorkbench(input: { workspaceId: string }): Promise<TaskWorkbenchSnapshot>;
createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(input: TaskWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>;
renameWorkbenchSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
updateWorkbenchDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
}
interface SandboxInstanceHandle {
createSession(input: { prompt: string; cwd?: string; agent?: AgentType | "opencode" }): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
listSessions(input?: { cursor?: string; limit?: number }): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
listSessionEvents(input: { sessionId: string; cursor?: string; limit?: number }): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
sessionStatus(input: { sessionId: string }): Promise<{ id: string; status: "running" | "idle" | "error" }>;
providerState(): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
}
interface RivetClient {
workspace: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle;
};
sandboxInstance: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle;
};
}
export interface BackendClientOptions {
endpoint: string;
defaultWorkspaceId?: string;
}
export interface BackendMetadata {
runtime?: string;
actorNames?: Record<string, unknown>;
clientEndpoint?: string;
clientNamespace?: string;
clientToken?: string;
}
export interface BackendClient {
getAppSnapshot(): Promise<FoundryAppSnapshot>;
signInWithGithub(): Promise<void>;
signOutApp(): Promise<FoundryAppSnapshot>;
selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot>;
reconnectAppGithub(organizationId: string): Promise<void>;
completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;
openAppBillingPortal(organizationId: string): Promise<void>;
cancelAppScheduledRenewal(organizationId: string): Promise<FoundryAppSnapshot>;
resumeAppSubscription(organizationId: string): Promise<FoundryAppSnapshot>;
recordAppSeatUsage(workspaceId: string): Promise<FoundryAppSnapshot>;
addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord>;
listRepos(workspaceId: string): Promise<RepoRecord[]>;
createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(workspaceId: string, repoId?: string): Promise<TaskSummary[]>;
getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
getTask(workspaceId: string, taskId: string): Promise<TaskRecord>;
listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(workspaceId: string, taskId: string): Promise<SwitchResult>;
attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void>;
runTaskAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void>;
createSandboxSession(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
prompt: string;
cwd?: string;
agent?: AgentType | "opencode";
}): Promise<{ id: string; status: "running" | "idle" | "error" }>;
listSandboxSessions(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input?: { cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
listSandboxSessionEvents(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input: { sessionId: string; cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
sendSandboxPrompt(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
prompt: string;
notification?: boolean;
}): Promise<void>;
sandboxSessionStatus(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
sessionId: string
): Promise<{ id: string; status: "running" | "idle" | "error" }>;
sandboxProviderState(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchTask(
workspaceId: string,
input: TaskWorkbenchCreateTaskInput
): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(
workspaceId: string,
input: TaskWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }>;
renameWorkbenchSession(workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(
workspaceId: string,
input: TaskWorkbenchSetSessionUnreadInput
): Promise<void>;
updateWorkbenchDraft(workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void>;
health(): Promise<{ ok: true }>;
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
}
export function rivetEndpoint(config: AppConfig): string {
return `http://${config.backend.host}:${config.backend.port}/api/rivet`;
}
export function createBackendClientFromConfig(config: AppConfig): BackendClient {
return createBackendClient({
endpoint: rivetEndpoint(config),
defaultWorkspaceId: config.workspace.default
});
}
function isLoopbackHost(hostname: string): boolean {
const h = hostname.toLowerCase();
return h === "127.0.0.1" || h === "localhost" || h === "0.0.0.0" || h === "::1";
}
function rewriteLoopbackClientEndpoint(clientEndpoint: string, fallbackOrigin: string): string {
const clientUrl = new URL(clientEndpoint);
if (!isLoopbackHost(clientUrl.hostname)) {
return clientUrl.toString().replace(/\/$/, "");
}
const originUrl = new URL(fallbackOrigin);
// Keep the manager port from clientEndpoint; only rewrite host/protocol to match the origin.
clientUrl.hostname = originUrl.hostname;
clientUrl.protocol = originUrl.protocol;
return clientUrl.toString().replace(/\/$/, "");
}
async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise<unknown> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`request failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as unknown;
} finally {
clearTimeout(timeout);
}
}
async function fetchMetadataWithRetry(
endpoint: string,
namespace: string | undefined,
opts: { timeoutMs: number; requestTimeoutMs: number }
): Promise<RivetMetadataResponse> {
const base = new URL(endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
if (namespace) {
base.searchParams.set("namespace", namespace);
}
const start = Date.now();
let delayMs = 250;
// Keep this bounded: callers (UI/CLI) should not hang forever if the backend is down.
for (;;) {
try {
const json = await fetchJsonWithTimeout(base.toString(), opts.requestTimeoutMs);
if (!json || typeof json !== "object") return {};
const data = json as Record<string, unknown>;
return {
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
actorNames:
data.actorNames && typeof data.actorNames === "object"
? (data.actorNames as Record<string, unknown>)
: undefined,
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
};
} catch (err) {
if (Date.now() - start > opts.timeoutMs) {
throw err;
}
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 2, 2_000);
}
}
}
export async function readBackendMetadata(input: {
endpoint: string;
namespace?: string;
timeoutMs?: number;
}): Promise<BackendMetadata> {
const base = new URL(input.endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
if (input.namespace) {
base.searchParams.set("namespace", input.namespace);
}
const json = await fetchJsonWithTimeout(base.toString(), input.timeoutMs ?? 4_000);
if (!json || typeof json !== "object") {
return {};
}
const data = json as Record<string, unknown>;
return {
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
actorNames:
data.actorNames && typeof data.actorNames === "object"
? (data.actorNames as Record<string, unknown>)
: undefined,
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
};
}
export async function checkBackendHealth(input: {
endpoint: string;
namespace?: string;
timeoutMs?: number;
}): Promise<boolean> {
try {
const metadata = await readBackendMetadata(input);
return metadata.runtime === "rivetkit" && Boolean(metadata.actorNames);
} catch {
return false;
}
}
async function probeMetadataEndpoint(
endpoint: string,
namespace: string | undefined,
timeoutMs: number
): Promise<boolean> {
try {
const base = new URL(endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
if (namespace) {
base.searchParams.set("namespace", namespace);
}
await fetchJsonWithTimeout(base.toString(), timeoutMs);
return true;
} catch {
return false;
}
}
export function createBackendClient(options: BackendClientOptions): BackendClient {
let clientPromise: Promise<RivetClient> | null = null;
let appSessionId =
typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-foundry: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-foundry:remote-app-session", nextSessionId);
} else {
window.localStorage.removeItem("sandbox-agent-foundry:remote-app-session");
}
};
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
const sessionFromUrl = url.searchParams.get("foundrySession");
if (sessionFromUrl) {
persistAppSessionId(sessionFromUrl);
url.searchParams.delete("foundrySession");
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
}
}
const appRequest = async <T>(path: string, init?: RequestInit): Promise<T> => {
const headers = new Headers(init?.headers);
if (appSessionId) {
headers.set("x-foundry-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,
credentials: "include",
});
const nextSessionId = res.headers.get("x-foundry-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 redirectTo = async (path: string, init?: RequestInit): Promise<void> => {
const response = await appRequest<{ url: string }>(path, init);
if (typeof window !== "undefined") {
window.location.assign(response.url);
}
};
const getClient = async (): Promise<RivetClient> => {
if (clientPromise) {
return clientPromise;
}
clientPromise = (async () => {
// Use the serverless /metadata endpoint to discover the manager endpoint.
// If the server reports a loopback clientEndpoint (127.0.0.1), rewrite to the same host
// as the configured endpoint so remote browsers/clients can connect.
const configured = new URL(options.endpoint);
const configuredOrigin = `${configured.protocol}//${configured.host}`;
const initialNamespace = undefined;
const metadata = await fetchMetadataWithRetry(options.endpoint, initialNamespace, {
timeoutMs: 30_000,
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)
: options.endpoint;
// 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 && !isBrowserRuntime
? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500)
: false;
const resolvedEndpoint = isBrowserRuntime
? options.endpoint
: shouldUseCandidate
? candidateEndpoint
: options.endpoint;
return createClient({
endpoint: resolvedEndpoint,
namespace: metadata.clientNamespace,
token: metadata.clientToken,
// Prevent rivetkit from overriding back to a loopback endpoint (or to an unreachable manager).
disableMetadataLookup: true,
}) as unknown as RivetClient;
})();
return clientPromise;
};
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
(await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId
});
const sandboxByKey = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle> => {
const client = await getClient();
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
};
function isActorNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Actor not found");
}
const sandboxByActorIdFromTask = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle | null> => {
const ws = await workspace(workspaceId);
const rows = await ws.listTasks({ workspaceId });
const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
for (const row of candidates) {
try {
const detail = await ws.getTask({ workspaceId, taskId: row.taskId });
if (detail.providerId !== providerId) {
continue;
}
const sandbox = detail.sandboxes.find((sb) =>
sb.sandboxId === sandboxId &&
sb.providerId === providerId &&
typeof (sb as any).sandboxActorId === "string" &&
(sb as any).sandboxActorId.length > 0
) as ({ sandboxActorId?: string } | undefined);
if (sandbox?.sandboxActorId) {
const client = await getClient();
return (client as any).sandboxInstance.getForId(sandbox.sandboxActorId);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isActorNotFoundError(error) && !message.includes("Unknown task")) {
throw error;
}
// Best effort fallback path; ignore missing task actors here.
}
}
return null;
};
const withSandboxHandle = async <T>(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
run: (handle: SandboxInstanceHandle) => Promise<T>
): Promise<T> => {
const handle = await sandboxByKey(workspaceId, providerId, sandboxId);
try {
return await run(handle);
} catch (error) {
if (!isActorNotFoundError(error)) {
throw error;
}
const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId);
if (!fallback) {
throw error;
}
return await run(fallback);
}
};
const subscribeWorkbench = (workspaceId: string, listener: () => void): (() => void) => {
let entry = workbenchSubscriptions.get(workspaceId);
if (!entry) {
entry = {
listeners: new Set(),
disposeConnPromise: null,
pollInterval: null,
};
workbenchSubscriptions.set(workspaceId, entry);
}
entry.listeners.add(listener);
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();
const unsubscribeEvent = conn.on("workbenchUpdated", () => {
const current = workbenchSubscriptions.get(workspaceId);
if (!current) {
return;
}
for (const currentListener of [...current.listeners]) {
currentListener();
}
});
const unsubscribeError = conn.onError(() => {});
return async () => {
unsubscribeEvent();
unsubscribeError();
await conn.dispose();
};
})().catch(() => null);
}
return () => {
const current = workbenchSubscriptions.get(workspaceId);
if (!current) {
return;
}
current.listeners.delete(listener);
if (current.listeners.size > 0) {
return;
}
workbenchSubscriptions.delete(workspaceId);
if (current.pollInterval) {
clearInterval(current.pollInterval);
current.pollInterval = null;
}
void current.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
};
};
return {
async getAppSnapshot(): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
},
async signInWithGithub(): Promise<void> {
if (typeof window !== "undefined") {
window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`);
return;
}
await redirectTo("/app/auth/github/start");
},
async signOutApp(): Promise<FoundryAppSnapshot> {
const snapshot = await appRequest<FoundryAppSnapshot>("/app/sign-out", { method: "POST" });
persistAppSessionId(appSessionId);
return snapshot;
},
async selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/select`, {
method: "POST",
});
},
async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${input.organizationId}/profile`, {
method: "PATCH",
body: JSON.stringify({
displayName: input.displayName,
slug: input.slug,
primaryDomain: input.primaryDomain,
}),
});
},
async triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/import`, {
method: "POST",
});
},
async reconnectAppGithub(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/reconnect`, {
method: "POST",
});
},
async completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/billing/checkout`, {
method: "POST",
body: JSON.stringify({ planId }),
});
},
async openAppBillingPortal(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/billing/portal`, {
method: "POST",
});
},
async cancelAppScheduledRenewal(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/billing/cancel`, {
method: "POST",
});
},
async resumeAppSubscription(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/billing/resume`, {
method: "POST",
});
},
async recordAppSeatUsage(workspaceId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/workspaces/${workspaceId}/seat-usage`, {
method: "POST",
});
},
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl });
},
async listRepos(workspaceId: string): Promise<RepoRecord[]> {
return (await workspace(workspaceId)).listRepos({ workspaceId });
},
async createTask(input: CreateTaskInput): Promise<TaskRecord> {
return (await workspace(input.workspaceId)).createTask(input);
},
async listTasks(workspaceId: string, repoId?: string): Promise<TaskSummary[]> {
return (await workspace(workspaceId)).listTasks({ workspaceId, repoId });
},
async getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview> {
return (await workspace(workspaceId)).getRepoOverview({ workspaceId, repoId });
},
async runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult> {
return (await workspace(input.workspaceId)).runRepoStackAction(input);
},
async getTask(workspaceId: string, taskId: string): Promise<TaskRecord> {
return (await workspace(workspaceId)).getTask({
workspaceId,
taskId
});
},
async listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]> {
return (await workspace(input.workspaceId)).history(input);
},
async switchTask(workspaceId: string, taskId: string): Promise<SwitchResult> {
return (await workspace(workspaceId)).switchTask(taskId);
},
async attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
return (await workspace(workspaceId)).attachTask({
workspaceId,
taskId,
reason: "cli.attach"
});
},
async runAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void> {
if (action === "push") {
await (await workspace(workspaceId)).pushTask({
workspaceId,
taskId,
reason: "cli.push"
});
return;
}
if (action === "sync") {
await (await workspace(workspaceId)).syncTask({
workspaceId,
taskId,
reason: "cli.sync"
});
return;
}
if (action === "merge") {
await (await workspace(workspaceId)).mergeTask({
workspaceId,
taskId,
reason: "cli.merge"
});
return;
}
if (action === "archive") {
await (await workspace(workspaceId)).archiveTask({
workspaceId,
taskId,
reason: "cli.archive"
});
return;
}
await (await workspace(workspaceId)).killTask({
workspaceId,
taskId,
reason: "cli.kill"
});
},
async runTaskAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void> {
await this.runAction(workspaceId, taskId, action);
},
async createSandboxSession(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
prompt: string;
cwd?: string;
agent?: AgentType | "opencode";
}): Promise<{ id: string; status: "running" | "idle" | "error" }> {
const created = await withSandboxHandle(
input.workspaceId,
input.providerId,
input.sandboxId,
async (handle) =>
handle.createSession({
prompt: input.prompt,
cwd: input.cwd,
agent: input.agent
})
);
if (!created.id) {
throw new Error(created.error ?? "sandbox session creation failed");
}
return {
id: created.id,
status: created.status
};
},
async listSandboxSessions(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input?: { cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listSessions(input ?? {})
);
},
async listSandboxSessionEvents(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input: { sessionId: string; cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listSessionEvents(input)
);
},
async sendSandboxPrompt(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
prompt: string;
notification?: boolean;
}): Promise<void> {
await withSandboxHandle(
input.workspaceId,
input.providerId,
input.sandboxId,
async (handle) =>
handle.sendPrompt({
sessionId: input.sessionId,
prompt: input.prompt,
notification: input.notification
})
);
},
async sandboxSessionStatus(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
sessionId: string
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.sessionStatus({ sessionId })
);
},
async sandboxProviderState(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.providerState()
);
},
async getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot> {
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
},
subscribeWorkbench(workspaceId: string, listener: () => void): () => void {
return subscribeWorkbench(workspaceId, listener);
},
async createWorkbenchTask(
workspaceId: string,
input: TaskWorkbenchCreateTaskInput
): Promise<TaskWorkbenchCreateTaskResponse> {
return (await workspace(workspaceId)).createWorkbenchTask(input);
},
async markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).markWorkbenchUnread(input);
},
async renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchTask(input);
},
async renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchBranch(input);
},
async createWorkbenchSession(
workspaceId: string,
input: TaskWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }> {
return await (await workspace(workspaceId)).createWorkbenchSession(input);
},
async renameWorkbenchSession(
workspaceId: string,
input: TaskWorkbenchRenameSessionInput
): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(
workspaceId: string,
input: TaskWorkbenchSetSessionUnreadInput
): Promise<void> {
await (await workspace(workspaceId)).setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(
workspaceId: string,
input: TaskWorkbenchUpdateDraftInput
): Promise<void> {
await (await workspace(workspaceId)).updateWorkbenchDraft(input);
},
async changeWorkbenchModel(
workspaceId: string,
input: TaskWorkbenchChangeModelInput
): Promise<void> {
await (await workspace(workspaceId)).changeWorkbenchModel(input);
},
async sendWorkbenchMessage(
workspaceId: string,
input: TaskWorkbenchSendMessageInput
): Promise<void> {
await (await workspace(workspaceId)).sendWorkbenchMessage(input);
},
async stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).stopWorkbenchSession(input);
},
async closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).closeWorkbenchSession(input);
},
async publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).publishWorkbenchPr(input);
},
async revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void> {
await (await workspace(workspaceId)).revertWorkbenchFile(input);
},
async health(): Promise<{ ok: true }> {
const workspaceId = options.defaultWorkspaceId;
if (!workspaceId) {
throw new Error("Backend client default workspace is required for health checks");
}
await (await workspace(workspaceId)).useWorkspace({
workspaceId
});
return { ok: true };
},
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
return (await workspace(workspaceId)).useWorkspace({ workspaceId });
}
};
}

View file

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

View file

@ -0,0 +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";

View file

@ -0,0 +1,44 @@
export type ActorKey = string[];
export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
}
export function repoKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId];
}
export function taskKey(workspaceId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "task", taskId];
}
export function sandboxInstanceKey(
workspaceId: string,
providerId: string,
sandboxId: string,
): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
}
export function historyKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "history"];
}
export function repoPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "pr-sync"];
}
export function repoBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "branch-sync"];
}
export function taskStatusSyncKey(
workspaceId: string,
repoId: string,
taskId: string,
sandboxId: string,
sessionId: string,
): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
return ["ws", workspaceId, "task", taskId, "status-sync", repoId, sandboxId, sessionId];
}

View file

@ -0,0 +1,636 @@
import { injectMockLatency } from "./mock/latency.js";
export type MockBillingPlanId = "free" | "team";
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
export type MockOrganizationKind = "personal" | "organization";
export interface MockFoundryUser {
id: string;
name: string;
email: string;
githubLogin: string;
roleLabel: string;
eligibleOrganizationIds: string[];
}
export interface MockFoundryOrganizationMember {
id: string;
name: string;
email: string;
role: "owner" | "admin" | "member";
state: "active" | "invited";
}
export interface MockFoundryInvoice {
id: string;
label: string;
issuedAt: string;
amountUsd: number;
status: "paid" | "open";
}
export interface MockFoundryBillingState {
planId: MockBillingPlanId;
status: MockBillingStatus;
seatsIncluded: number;
trialEndsAt: string | null;
renewalAt: string | null;
stripeCustomerId: string;
paymentMethodLabel: string;
invoices: MockFoundryInvoice[];
}
export interface MockFoundryGithubState {
connectedAccount: string;
installationStatus: MockGithubInstallationStatus;
syncStatus: MockGithubSyncStatus;
importedRepoCount: number;
lastSyncLabel: string;
lastSyncAt: number | null;
}
export interface MockFoundryOrganizationSettings {
displayName: string;
slug: string;
primaryDomain: string;
seatAccrualMode: "first_prompt";
defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
autoImportRepos: boolean;
}
export interface MockFoundryOrganization {
id: string;
workspaceId: string;
kind: MockOrganizationKind;
settings: MockFoundryOrganizationSettings;
github: MockFoundryGithubState;
billing: MockFoundryBillingState;
members: MockFoundryOrganizationMember[];
seatAssignments: string[];
repoCatalog: string[];
}
export interface MockFoundryAppSnapshot {
auth: {
status: "signed_out" | "signed_in";
currentUserId: string | null;
};
activeOrganizationId: string | null;
users: MockFoundryUser[];
organizations: MockFoundryOrganization[];
}
export interface UpdateMockOrganizationProfileInput {
organizationId: string;
displayName: string;
slug: string;
primaryDomain: string;
}
export interface MockFoundryAppClient {
getSnapshot(): MockFoundryAppSnapshot;
subscribe(listener: () => void): () => void;
signInWithGithub(userId: string): Promise<void>;
signOut(): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): 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-foundry:mock-app:v1";
function isoDate(daysFromNow: number): string {
const value = new Date();
value.setDate(value.getDate() + daysFromNow);
return value.toISOString();
}
function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus {
switch (value) {
case "ready":
case "synced":
return "synced";
case "importing":
case "syncing":
return "syncing";
case "error":
return "error";
default:
return "pending";
}
}
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
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",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
lastSyncAt: Date.now() - 60_000,
},
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"],
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",
syncStatus: "pending",
importedRepoCount: 3,
lastSyncLabel: "Waiting for first import",
lastSyncAt: null,
},
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"],
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",
syncStatus: "error",
importedRepoCount: 4,
lastSyncLabel: "Sync stalled 2 hours ago",
lastSyncAt: Date.now() - 2 * 60 * 60_000,
},
billing: {
planId: "team",
status: "trialing",
seatsIncluded: 5,
trialEndsAt: isoDate(12),
renewalAt: isoDate(12),
stripeCustomerId: "cus_mock_rivet_team",
paymentMethodLabel: "Visa ending in 4242",
invoices: [{ id: "inv-rivet-001", label: "Team 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"],
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",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced yesterday",
lastSyncAt: Date.now() - 24 * 60 * 60_000,
},
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"],
repoCatalog: ["jamie/demo-app"],
},
],
};
}
function parseStoredSnapshot(): MockFoundryAppSnapshot | 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 MockFoundryAppSnapshot & {
organizations?: Array<MockFoundryOrganization & { repoImportStatus?: string }>;
};
if (!parsed || typeof parsed !== "object") {
return null;
}
return {
...parsed,
organizations: (parsed.organizations ?? []).map((organization: MockFoundryOrganization & { repoImportStatus?: string }) => ({
...organization,
github: {
...organization.github,
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
lastSyncAt: organization.github?.lastSyncAt ?? null,
},
})),
};
} catch {
return null;
}
}
function saveSnapshot(snapshot: MockFoundryAppSnapshot): 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;
}
}
class MockFoundryAppStore implements MockFoundryAppClient {
private snapshot = parseStoredSnapshot() ?? buildDefaultSnapshot();
private listeners = new Set<() => void>();
private importTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): MockFoundryAppSnapshot {
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.github.syncStatus !== "synced") {
await this.triggerGithubSync(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 triggerGithubSync(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,
github: {
...organization.github,
syncStatus: "syncing",
lastSyncLabel: "Syncing repositories...",
},
}));
const timer = setTimeout(() => {
this.updateOrganization(organizationId, (organization) => ({
...organization,
github: {
...organization.github,
importedRepoCount: organization.repoCatalog.length,
installationStatus: "connected",
syncStatus: "synced",
lastSyncLabel: "Synced just now",
lastSyncAt: Date.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: "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 : 0,
status: "paid",
},
...organization.billing.invoices,
],
},
}));
}
async openBillingPortal(_organizationId: string): Promise<void> {
await this.injectAsyncLatency();
}
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",
syncStatus: "pending",
lastSyncLabel: "Reconnected just now",
lastSyncAt: Date.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: MockFoundryOrganization) => MockFoundryOrganization,
): void {
this.updateSnapshot((current) => ({
...current,
organizations: current.organizations.map((organization) =>
organization.id === organizationId ? updater(organization) : organization,
),
}));
}
private updateSnapshot(updater: (current: MockFoundryAppSnapshot) => MockFoundryAppSnapshot): void {
this.snapshot = updater(this.snapshot);
saveSnapshot(this.snapshot);
for (const listener of this.listeners) {
listener();
}
}
private requireOrganization(organizationId: string): MockFoundryOrganization {
const organization = this.snapshot.organizations.find((candidate) => candidate.id === organizationId);
if (!organization) {
throw new Error(`Unknown mock organization ${organizationId}`);
}
return organization;
}
}
function currentMockUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null {
if (!snapshot.auth.currentUserId) {
return null;
}
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
}
const mockFoundryAppStore = new MockFoundryAppStore();
export function getMockFoundryAppClient(): MockFoundryAppClient {
return mockFoundryAppStore;
}
export function currentMockFoundryUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null {
return currentMockUser(snapshot);
}
export function currentMockFoundryOrganization(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization | null {
if (!snapshot.activeOrganizationId) {
return null;
}
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
}
export function eligibleMockOrganizations(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization[] {
const user = currentMockUser(snapshot);
if (!user) {
return [];
}
const eligible = new Set(user.eligibleOrganizationIds);
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
}

View 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());
});
}

View file

@ -0,0 +1,496 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchRepos,
nowMs,
providerAgent,
randomReply,
removeFileTreePath,
slugify,
uid,
} from "../workbench-model.js";
import { getMockFoundryAppClient } from "../mock-app.js";
import { injectMockLatency } from "./latency.js";
import type {
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
WorkbenchAgentTab as AgentTab,
TaskWorkbenchSnapshot,
WorkbenchTask as Task,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import type { TaskWorkbenchClient } from "../workbench-client.js";
function buildTranscriptEvent(params: {
sessionId: string;
sender: "client" | "agent";
createdAt: number;
payload: unknown;
eventIndex: number;
}): TranscriptEvent {
return {
id: uid(),
sessionId: params.sessionId,
sender: params.sender,
createdAt: params.createdAt,
payload: params.payload,
connectionId: "mock-connection",
eventIndex: params.eventIndex,
};
}
class MockWorkbenchStore implements TaskWorkbenchClient {
private snapshot: TaskWorkbenchSnapshot;
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(workspaceId: string) {
this.snapshot = buildInitialMockLayoutViewModel(workspaceId);
}
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
await this.injectAsyncLatency();
const id = uid();
const tabId = `session-${id}`;
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
if (!repo) {
throw new Error(`Cannot create mock task for unknown repo ${input.repoId}`);
}
const nextTask: Task = {
id,
repoId: repo.id,
repoIds: input.repoIds?.length ? [...new Set([repo.id, ...input.repoIds])] : [repo.id],
title: input.title?.trim() || "New Task",
status: "new",
repoName: repo.label,
updatedAtMs: nowMs(),
branch: input.branch?.trim() || null,
pullRequest: null,
tabs: [
{
id: tabId,
sessionId: tabId,
sessionName: "Session 1",
agent: providerAgent(MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude"),
model: input.model ?? "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
},
],
fileChanges: [],
diffs: {},
fileTree: [],
};
this.updateState((current) => ({
...current,
tasks: [nextTask, ...current.tasks],
}));
const task = input.task.trim();
if (task) {
await this.sendMessage({
taskId: id,
tabId,
text: task,
attachments: [],
});
}
return { taskId: id, tabId };
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
this.updateTask(input.taskId, (task) => {
const targetTab = task.tabs[task.tabs.length - 1] ?? null;
if (!targetTab) {
return task;
}
return {
...task,
tabs: task.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
};
});
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.injectAsyncLatency();
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename task ${input.taskId} to an empty title`);
}
this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() }));
}
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
await this.injectAsyncLatency();
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`);
}
this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() }));
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() }));
}
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1;
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
}));
}
async pushTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
}));
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.injectAsyncLatency();
this.updateTask(input.taskId, (task) => {
const file = task.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...task.diffs };
delete nextDiffs[input.path];
return {
...task,
fileChanges: task.fileChanges.filter((entry) => entry.path !== input.path),
diffs: nextDiffs,
fileTree: file?.type === "A" ? removeFileTreePath(task.fileTree, input.path) : task.fileTree,
};
});
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
this.assertTab(input.taskId, input.tabId);
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
tabs: task.tabs.map((tab) =>
tab.id === input.tabId
? {
...tab,
draft: {
text: input.text,
attachments: input.attachments,
updatedAtMs: nowMs(),
},
}
: tab,
),
}));
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.injectAsyncLatency();
const text = input.text.trim();
if (!text) {
throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`);
}
this.assertTab(input.taskId, input.tabId);
const startedAtMs = nowMs();
getMockFoundryAppClient().recordSeatUsage(this.snapshot.workspaceId);
this.updateTask(input.taskId, (currentTask) => {
const isFirstOnTask = currentTask.status === "new";
const synthesizedTitle = text.length > 50 ? `${text.slice(0, 47)}...` : text;
const newTitle =
isFirstOnTask && currentTask.title === "New Task" ? synthesizedTitle : currentTask.title;
const newBranch =
isFirstOnTask && !currentTask.branch ? `feat/${slugify(synthesizedTitle)}` : currentTask.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "client",
createdAt: startedAtMs,
eventIndex: candidateEventIndex(currentTask, input.tabId),
payload: {
method: "session/prompt",
params: {
prompt: userMessageLines.map((line) => ({ type: "text", text: line })),
},
},
});
return {
...currentTask,
title: newTitle,
branch: newBranch,
status: "running",
updatedAtMs: startedAtMs,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId
? {
...candidate,
created: true,
status: "running",
unread: false,
thinkingSinceMs: startedAtMs,
draft: { text: "", attachments: [], updatedAtMs: startedAtMs },
transcript: [...candidate.transcript, userEvent],
}
: candidate,
),
};
});
const existingTimer = this.pendingTimers.get(input.tabId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
const task = this.requireTask(input.taskId);
const replyTab = this.requireTab(task, input.tabId);
const completedAtMs = nowMs();
const replyEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "agent",
createdAt: completedAtMs,
eventIndex: candidateEventIndex(task, input.tabId),
payload: {
result: {
text: randomReply(),
durationMs: completedAtMs - startedAtMs,
},
},
});
this.updateTask(input.taskId, (currentTask) => {
const updatedTabs = currentTask.tabs.map((candidate) => {
if (candidate.id !== input.tabId) {
return candidate;
}
return {
...candidate,
status: "idle" as const,
thinkingSinceMs: null,
unread: true,
transcript: [...candidate.transcript, replyEvent],
};
});
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentTask,
updatedAtMs: completedAtMs,
tabs: updatedTabs,
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
this.pendingTimers.delete(input.tabId);
}, 2_500);
this.pendingTimers.set(input.tabId, timer);
}
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
await this.injectAsyncLatency();
this.assertTab(input.taskId, input.tabId);
const existing = this.pendingTimers.get(input.tabId);
if (existing) {
clearTimeout(existing);
this.pendingTimers.delete(input.tabId);
}
this.updateTask(input.taskId, (currentTask) => {
const updatedTabs = currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
);
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentTask,
updatedAtMs: nowMs(),
tabs: updatedTabs,
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.injectAsyncLatency();
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate,
),
}));
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.injectAsyncLatency();
const title = input.title.trim();
if (!title) {
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
}
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate,
),
}));
}
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
await this.injectAsyncLatency();
this.updateTask(input.taskId, (currentTask) => {
if (currentTask.tabs.length <= 1) {
return currentTask;
}
return {
...currentTask,
tabs: currentTask.tabs.filter((candidate) => candidate.id !== input.tabId),
};
});
}
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
await this.injectAsyncLatency();
this.assertTask(input.taskId);
const nextTab: AgentTab = {
id: uid(),
sessionId: null,
sessionName: `Session ${this.requireTask(input.taskId).tabs.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
};
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
tabs: [...currentTask.tabs, nextTab],
}));
return { tabId: nextTab.id };
}
async changeModel(input: TaskWorkbenchChangeModelInput): 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}`);
}
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
),
}));
}
private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void {
const nextSnapshot = updater(this.snapshot);
this.snapshot = {
...nextSnapshot,
repoSections: groupWorkbenchRepos(nextSnapshot.repos, nextSnapshot.tasks),
tasks: nextSnapshot.tasks,
};
this.notify();
}
private updateTask(taskId: string, updater: (task: Task) => Task): void {
this.assertTask(taskId);
this.updateState((current) => ({
...current,
tasks: current.tasks.map((task) => (task.id === taskId ? updater(task) : task)),
}));
}
private notify(): void {
for (const listener of this.listeners) {
listener();
}
}
private assertTask(taskId: string): void {
this.requireTask(taskId);
}
private assertTab(taskId: string, tabId: string): void {
const task = this.requireTask(taskId);
this.requireTab(task, tabId);
}
private requireTask(taskId: string): Task {
const task = this.snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`Unable to find mock task ${taskId}`);
}
return task;
}
private requireTab(task: Task, tabId: string): AgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`Unable to find mock tab ${tabId} in task ${task.id}`);
}
return tab;
}
private injectAsyncLatency(): Promise<void> {
return injectMockLatency();
}
}
function candidateEventIndex(task: Task, tabId: string): number {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
return (tab?.transcript.length ?? 0) + 1;
}
const mockWorkbenchClients = new Map<string, TaskWorkbenchClient>();
export function getMockWorkbenchClient(workspaceId = "default"): TaskWorkbenchClient {
let client = mockWorkbenchClients.get(workspaceId);
if (!client) {
client = new MockWorkbenchStore(workspaceId);
mockWorkbenchClients.set(workspaceId, client);
}
return client;
}

View file

@ -0,0 +1,139 @@
import type {
FoundryAppSnapshot,
FoundryBillingPlanId,
UpdateFoundryOrganizationProfileInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import type { FoundryAppClient } from "../app-client.js";
export interface RemoteFoundryAppClientOptions {
backend: BackendClient;
}
class RemoteFoundryAppStore implements FoundryAppClient {
private readonly backend: BackendClient;
private snapshot: FoundryAppSnapshot = {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
users: [],
organizations: [],
};
private readonly listeners = new Set<() => void>();
private refreshPromise: Promise<void> | null = null;
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteFoundryAppClientOptions) {
this.backend = options.backend;
}
getSnapshot(): FoundryAppSnapshot {
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> {
void userId;
await this.backend.signInWithGithub();
}
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.scheduleSyncPollingIfNeeded();
}
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
this.snapshot = await this.backend.updateAppOrganizationProfile(input);
this.notify();
}
async triggerGithubSync(organizationId: string): Promise<void> {
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
this.notify();
this.scheduleSyncPollingIfNeeded();
}
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
await this.backend.completeAppHostedCheckout(organizationId, planId);
}
async openBillingPortal(organizationId: string): Promise<void> {
await this.backend.openAppBillingPortal(organizationId);
}
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> {
await this.backend.reconnectAppGithub(organizationId);
}
async recordSeatUsage(workspaceId: string): Promise<void> {
this.snapshot = await this.backend.recordAppSeatUsage(workspaceId);
this.notify();
}
private scheduleSyncPollingIfNeeded(): void {
if (this.syncPollTimeout) {
clearTimeout(this.syncPollTimeout);
this.syncPollTimeout = null;
}
if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) {
return;
}
this.syncPollTimeout = setTimeout(() => {
this.syncPollTimeout = 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.scheduleSyncPollingIfNeeded();
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
private notify(): void {
for (const listener of [...this.listeners]) {
listener();
}
}
}
export function createRemoteFoundryAppClient(
options: RemoteFoundryAppClientOptions,
): FoundryAppClient {
return new RemoteFoundryAppStore(options);
}

View file

@ -0,0 +1,206 @@
import type {
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
TaskWorkbenchSnapshot,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchRepos } from "../workbench-model.js";
import type { TaskWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
workspaceId: string;
}
class RemoteWorkbenchStore implements TaskWorkbenchClient {
private readonly backend: BackendClient;
private readonly workspaceId: string;
private snapshot: TaskWorkbenchSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkbench: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkbenchClientOptions) {
this.backend = options.backend;
this.workspaceId = options.workspaceId;
this.snapshot = {
workspaceId: options.workspaceId,
repos: [],
repoSections: [],
tasks: [],
};
}
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkbench) {
this.unsubscribeWorkbench();
this.unsubscribeWorkbench = null;
}
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const created = await this.backend.createWorkbenchTask(this.workspaceId, input);
await this.refresh();
return created;
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.markWorkbenchUnread(this.workspaceId, input);
await this.refresh();
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchTask(this.workspaceId, input);
await this.refresh();
}
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchBranch(this.workspaceId, input);
await this.refresh();
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.workspaceId, input);
await this.refresh();
}
async pushTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.taskId, "push");
await this.refresh();
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
await this.refresh();
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.backend.recordAppSeatUsage(this.workspaceId);
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
await this.refresh();
}
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.workspaceId, input);
await this.refresh();
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
const created = await this.backend.createWorkbenchSession(this.workspaceId, input);
await this.refresh();
return created;
}
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
await this.backend.changeWorkbenchModel(this.workspaceId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkbench) {
this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.workspaceId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkbench(this.workspaceId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
repoSections: nextSnapshot.repoSections ?? groupWorkbenchRepos(nextSnapshot.repos, nextSnapshot.tasks),
tasks: nextSnapshot.tasks,
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkbenchClient(
options: RemoteWorkbenchClientOptions,
): TaskWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -0,0 +1,118 @@
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",
"running",
"idle",
"archived",
"killed",
"error"
] as const;
export type TaskStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
const QUEUED_STATUSES = new Set<TaskStatus>([
"init_bootstrap_db",
"init_enqueue_provision",
"init_ensure_name",
"init_assert_name",
"init_create_sandbox",
"init_ensure_agent",
"init_start_sandbox_instance",
"init_create_session",
"init_write_db",
"init_start_status_sync",
"init_complete",
"archive_stop_status_sync",
"archive_release_sandbox",
"archive_finalize",
"kill_destroy_sandbox",
"kill_finalize"
]);
export function groupTaskStatus(status: TaskStatus): TaskStatusGroup {
if (status === "running") return "running";
if (status === "idle") return "idle";
if (status === "archived") return "archived";
if (status === "killed") return "killed";
if (status === "error") return "error";
if (QUEUED_STATUSES.has(status)) return "queued";
return "queued";
}
function emptyStatusCounts(): Record<TaskStatusGroup, number> {
return {
queued: 0,
running: 0,
idle: 0,
archived: 0,
killed: 0,
error: 0
};
}
export interface TaskSummary {
total: number;
byStatus: Record<TaskStatusGroup, number>;
byProvider: Record<string, number>;
}
export function fuzzyMatch(target: string, query: string): boolean {
const haystack = target.toLowerCase();
const needle = query.toLowerCase();
let i = 0;
for (const ch of needle) {
i = haystack.indexOf(ch, i);
if (i < 0) {
return false;
}
i += 1;
}
return true;
}
export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
const q = query.trim();
if (!q) {
return rows;
}
return rows.filter((row) => {
const fields = [
row.branchName ?? "",
row.title ?? "",
row.taskId,
row.task,
row.prAuthor ?? "",
row.reviewer ?? ""
];
return fields.some((field) => fuzzyMatch(field, q));
});
}
export function formatRelativeAge(updatedAt: number, now = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000));
if (deltaSeconds < 60) return `${deltaSeconds}s`;
const minutes = Math.floor(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function summarizeTasks(rows: TaskRecord[]): TaskSummary {
const byStatus = emptyStatusCounts();
const byProvider: Record<string, number> = {};
for (const row of rows) {
byStatus[groupTaskStatus(row.status)] += 1;
byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1;
}
return {
total: rows.length,
byStatus,
byProvider
};
}

View file

@ -0,0 +1,67 @@
import type {
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type TaskWorkbenchClientMode = "mock" | "remote";
export interface CreateTaskWorkbenchClientOptions {
mode: TaskWorkbenchClientMode;
backend?: BackendClient;
workspaceId?: string;
}
export interface TaskWorkbenchClient {
getSnapshot(): TaskWorkbenchSnapshot;
subscribe(listener: () => void): () => void;
createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameTask(input: TaskWorkbenchRenameInput): Promise<void>;
renameBranch(input: TaskWorkbenchRenameInput): Promise<void>;
archiveTask(input: TaskWorkbenchSelectInput): Promise<void>;
publishPr(input: TaskWorkbenchSelectInput): Promise<void>;
pushTask(input: TaskWorkbenchSelectInput): Promise<void>;
revertFile(input: TaskWorkbenchDiffInput): Promise<void>;
updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkbenchTabInput): Promise<void>;
setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
closeTab(input: TaskWorkbenchTabInput): Promise<void>;
addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse>;
changeModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
}
export function createTaskWorkbenchClient(
options: CreateTaskWorkbenchClientOptions,
): TaskWorkbenchClient {
if (options.mode === "mock") {
return getMockWorkbenchClient(options.workspaceId);
}
if (!options.backend) {
throw new Error("Remote task workbench client requires a backend client");
}
if (!options.workspaceId) {
throw new Error("Remote task workbench client requires a workspace id");
}
return createRemoteWorkbenchClient({
backend: options.backend,
workspaceId: options.workspaceId,
});
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1";
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function parseGithubRepo(input: string): { fullName: string } {
const trimmed = input.trim();
const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
if (shorthand) {
return { fullName: `${shorthand[1]}/${shorthand[2]}` };
}
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) {
return { fullName: `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}` };
}
throw new Error(`Unable to parse GitHub repo from: ${input}`);
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function poll<T>(
label: string,
timeoutMs: number,
intervalMs: number,
fn: () => Promise<T>,
isDone: (value: T) => boolean
): Promise<T> {
const start = Date.now();
let last: T;
for (;;) {
last = await fn();
if (isDone(last)) {
return last;
}
if (Date.now() - start > timeoutMs) {
throw new Error(`timed out waiting for ${label}`);
}
await sleep(intervalMs);
}
}
function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
try {
return JSON.parse(event.payloadJson) as Record<string, unknown>;
} catch {
return {};
}
}
async function githubApi(token: string, path: string, init?: RequestInit): Promise<Response> {
const url = `https://api.github.com/${path.replace(/^\/+/, "")}`;
return await fetch(url, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
...(init?.headers ?? {}),
},
});
}
async function ensureRemoteBranchExists(
token: string,
fullName: string,
branchName: string
): Promise<void> {
const repoRes = await githubApi(token, `repos/${fullName}`, { method: "GET" });
if (!repoRes.ok) {
throw new Error(`GitHub repo lookup failed: ${repoRes.status} ${await repoRes.text()}`);
}
const repo = (await repoRes.json()) as { default_branch?: string };
const defaultBranch = repo.default_branch;
if (!defaultBranch) {
throw new Error(`GitHub repo default branch is missing for ${fullName}`);
}
const defaultRefRes = await githubApi(
token,
`repos/${fullName}/git/ref/heads/${encodeURIComponent(defaultBranch)}`,
{ method: "GET" }
);
if (!defaultRefRes.ok) {
throw new Error(`GitHub default ref lookup failed: ${defaultRefRes.status} ${await defaultRefRes.text()}`);
}
const defaultRef = (await defaultRefRes.json()) as { object?: { sha?: string } };
const sha = defaultRef.object?.sha;
if (!sha) {
throw new Error(`GitHub default ref sha missing for ${fullName}:${defaultBranch}`);
}
const createRefRes = await githubApi(token, `repos/${fullName}/git/refs`, {
method: "POST",
body: JSON.stringify({
ref: `refs/heads/${branchName}`,
sha,
}),
headers: { "Content-Type": "application/json" },
});
if (createRefRes.ok || createRefRes.status === 422) {
return;
}
throw new Error(`GitHub create ref failed: ${createRefRes.status} ${await createRefRes.text()}`);
}
describe("e2e(client): full integration stack workflow", () => {
it.skipIf(!RUN_FULL_E2E)(
"adds repo, loads branch graph, and executes a stack restack action",
{ timeout: 8 * 60_000 },
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 githubToken = requiredEnv("GITHUB_TOKEN");
const { fullName } = parseGithubRepo(repoRemote);
const normalizedRepoRemote = `https://github.com/${fullName}.git`;
const seededBranch = `e2e/full-seed-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
const client = createBackendClient({
endpoint,
defaultWorkspaceId: workspaceId,
});
try {
await ensureRemoteBranchExists(githubToken, fullName, seededBranch);
const repo = await client.addRepo(workspaceId, repoRemote);
expect(repo.remoteUrl).toBe(normalizedRepoRemote);
const overview = await poll<RepoOverview>(
"repo overview includes seeded branch",
90_000,
1_000,
async () => client.getRepoOverview(workspaceId, repo.repoId),
(value) => value.branches.some((row) => row.branchName === seededBranch)
);
if (!overview.stackAvailable) {
throw new Error(
"git-spice is unavailable for this repo during full integration e2e; set HF_GIT_SPICE_BIN or install git-spice in the backend container"
);
}
const stackResult = await client.runRepoStackAction({
workspaceId,
repoId: repo.repoId,
action: "restack_repo",
});
expect(stackResult.executed).toBe(true);
expect(stackResult.action).toBe("restack_repo");
await poll<HistoryEvent[]>(
"repo stack action history event",
60_000,
1_000,
async () => client.listHistory({ workspaceId, limit: 200 }),
(events) =>
events.some((event) => {
if (event.kind !== "repo.stack_action") {
return false;
}
const payload = parseHistoryPayload(event);
return payload.action === "restack_repo";
})
);
const postActionOverview = await client.getRepoOverview(workspaceId, repo.repoId);
const seededRow = postActionOverview.branches.find((row) => row.branchName === seededBranch);
expect(Boolean(seededRow)).toBe(true);
expect(postActionOverview.fetchedAt).toBeGreaterThan(overview.fetchedAt);
} finally {
await githubApi(
githubToken,
`repos/${fullName}/git/refs/heads/${encodeURIComponent(seededBranch)}`,
{ method: "DELETE" }
).catch(() => {});
}
}
);
});

View file

@ -0,0 +1,351 @@
import { describe, expect, it } from "vitest";
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function parseGithubRepo(input: string): { owner: string; repo: string; fullName: string } {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("HF_E2E_GITHUB_REPO is empty");
}
// owner/repo shorthand
const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
if (shorthand) {
const owner = shorthand[1]!;
const repo = shorthand[2]!;
return { owner, repo, fullName: `${owner}/${repo}` };
}
// https://github.com/owner/repo(.git)?(/...)?
try {
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) {
const owner = parts[0]!;
const repo = (parts[1] ?? "").replace(/\.git$/, "");
if (owner && repo) {
return { owner, repo, fullName: `${owner}/${repo}` };
}
}
} catch {
// fall through
}
throw new Error(`Unable to parse GitHub repo from: ${input}`);
}
async function sleep(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
async function poll<T>(
label: string,
timeoutMs: number,
intervalMs: number,
fn: () => Promise<T>,
isDone: (value: T) => boolean,
onTick?: (value: T) => void
): Promise<T> {
const start = Date.now();
let last: T;
for (;;) {
last = await fn();
onTick?.(last);
if (isDone(last)) {
return last;
}
if (Date.now() - start > timeoutMs) {
throw new Error(`timed out waiting for ${label}`);
}
await sleep(intervalMs);
}
}
function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
try {
return JSON.parse(event.payloadJson) as Record<string, unknown>;
} catch {
return {};
}
}
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, taskId: string): Promise<string> {
try {
const task = await client.getTask(workspaceId, taskId);
const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`)
.join("\n");
let sessionEventsSummary = "";
if (task.activeSandboxId && task.activeSessionId) {
const events = await client
.listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, {
sessionId: task.activeSessionId,
limit: 50,
})
.then((r) => r.items)
.catch(() => []);
sessionEventsSummary = events
.slice(-12)
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.sender}`)
.join("\n");
}
return [
"=== task ===",
JSON.stringify(
{
status: task.status,
statusMessage: task.statusMessage,
title: task.title,
branchName: task.branchName,
activeSandboxId: task.activeSandboxId,
activeSessionId: task.activeSessionId,
prUrl: task.prUrl,
prSubmitted: task.prSubmitted,
},
null,
2
),
"=== history (most recent first) ===",
historySummary || "(none)",
"=== session events (tail) ===",
sessionEventsSummary || "(none)",
].join("\n");
} catch (err) {
return `debug dump failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
async function githubApi(token: string, path: string, init?: RequestInit): Promise<Response> {
const url = `https://api.github.com/${path.replace(/^\/+/, "")}`;
return await fetch(url, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
...(init?.headers ?? {}),
},
});
}
describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
it.skipIf(!RUN_E2E)(
"creates a task, waits for agent to implement, and opens a PR",
{ timeout: 15 * 60_000 },
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 githubToken = requiredEnv("GITHUB_TOKEN");
const { fullName } = parseGithubRepo(repoRemote);
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const expectedFile = `e2e/${runId}.txt`;
const client = createBackendClient({
endpoint,
defaultWorkspaceId: workspaceId,
});
const repo = await client.addRepo(workspaceId, repoRemote);
const created = await client.createTask({
workspaceId,
repoId: repo.repoId,
task: [
"E2E test task:",
`1. Create a new file at ${expectedFile} containing the single line: ${runId}`,
"2. git add the file",
`3. git commit -m \"test(e2e): ${runId}\"`,
"4. git push the branch to origin",
"5. Stop when done (agent should go idle).",
].join("\n"),
providerId: "daytona",
explicitTitle: `test(e2e): ${runId}`,
explicitBranchName: `e2e/${runId}`,
});
let prNumber: number | null = null;
let branchName: string | null = null;
let sandboxId: string | null = null;
let sessionId: string | null = null;
let lastStatus: string | null = null;
try {
const namedAndProvisioned = await poll<TaskRecord>(
"task naming + sandbox provisioning",
// Cold Daytona snapshot/image preparation can exceed 5 minutes on first run.
8 * 60_000,
1_000,
async () => client.getTask(workspaceId, created.taskId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
lastStatus = h.status;
}
if (h.status === "error") {
throw new Error("task entered error state during provisioning");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<TaskRecord>(
"task to create active session",
3 * 60_000,
1_500,
async () => client.getTask(workspaceId, created.taskId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
throw new Error("task entered error state while waiting for active session");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
sessionId = withSession.activeSessionId!;
await poll<{ id: string }[]>(
"session transcript bootstrap events",
2 * 60_000,
2_000,
async () =>
(
await client.listSandboxSessionEvents(workspaceId, withSession.providerId, sandboxId!, {
sessionId: sessionId!,
limit: 40,
})
).items,
(events) => events.length > 0
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
await poll<TaskRecord>(
"task to reach idle state",
8 * 60_000,
2_000,
async () => client.getTask(workspaceId, created.taskId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
throw new Error("task entered error state while waiting for idle");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
const prCreatedEvent = await poll<HistoryEvent[]>(
"PR creation history event",
3 * 60_000,
2_000,
async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }),
(events) => events.some((e) => e.kind === "task.pr_created")
)
.catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
const payload = parseHistoryPayload(prCreatedEvent);
prNumber = Number(payload.prNumber);
const prUrl = String(payload.prUrl ?? "");
expect(prNumber).toBeGreaterThan(0);
expect(prUrl).toContain("/pull/");
const prFilesRes = await githubApi(
githubToken,
`repos/${fullName}/pulls/${prNumber}/files?per_page=100`,
{ method: "GET" }
);
if (!prFilesRes.ok) {
const body = await prFilesRes.text();
throw new Error(`GitHub PR files request failed: ${prFilesRes.status} ${body}`);
}
const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>;
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
// Close the task and assert the sandbox is released (stopped).
await client.runAction(workspaceId, created.taskId, "archive");
await poll<TaskRecord>(
"task to become archived (session released)",
60_000,
1_000,
async () => client.getTask(workspaceId, created.taskId),
(h) => h.status === "archived" && h.activeSessionId === null
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
if (sandboxId) {
await poll<{ providerId: string; sandboxId: string; state: string; at: number }>(
"daytona sandbox to stop",
2 * 60_000,
2_000,
async () => client.sandboxProviderState(workspaceId, "daytona", sandboxId!),
(s) => {
const st = String(s.state).toLowerCase();
return st.includes("stopped") || st.includes("suspended") || st.includes("paused");
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.taskId);
const state = await client
.sandboxProviderState(workspaceId, "daytona", sandboxId!)
.catch(() => null);
throw new Error(
`${err instanceof Error ? err.message : String(err)}\n` +
`sandbox state: ${state ? state.state : "unknown"}\n` +
`${dump}`
);
});
}
} finally {
if (prNumber && Number.isFinite(prNumber)) {
await githubApi(githubToken, `repos/${fullName}/pulls/${prNumber}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed" }),
headers: { "Content-Type": "application/json" },
}).catch(() => {});
}
if (branchName) {
await githubApi(
githubToken,
`repos/${fullName}/git/refs/heads/${encodeURIComponent(branchName)}`,
{ method: "DELETE" }
).catch(() => {});
}
}
}
);
});

View file

@ -0,0 +1,393 @@
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 {
TaskRecord,
TaskWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchTask,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1";
const execFileAsync = promisify(execFile);
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
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) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-4o":
case "o3":
return value;
default:
return fallback;
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
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: TaskRecord): 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 task ${record.taskId}`);
}
return cwd;
}
async function seedSandboxFile(endpoint: string, record: TaskRecord, 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", containerName, "bash", "-lc", script]);
}
async function poll<T>(
label: string,
timeoutMs: number,
intervalMs: number,
fn: () => Promise<T>,
isDone: (value: T) => boolean,
): Promise<T> {
const startedAt = Date.now();
let lastValue: T;
for (;;) {
lastValue = await fn();
if (isDone(lastValue)) {
return lastValue;
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`timed out waiting for ${label}`);
}
await sleep(intervalMs);
}
}
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
}
return task;
}
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from task ${task.id}`);
}
return tab;
}
function extractEventText(event: WorkbenchTranscriptEvent): string {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
}
const envelope = payload as {
method?: unknown;
params?: unknown;
result?: unknown;
error?: unknown;
};
const params = envelope.params;
if (params && typeof params === "object") {
const update = (params as { update?: unknown }).update;
if (update && typeof update === "object") {
const content = (update as { content?: unknown }).content;
if (content && typeof content === "object") {
const chunkText = (content as { text?: unknown }).text;
if (typeof chunkText === "string") {
return chunkText;
}
}
}
const text = (params as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt;
if (Array.isArray(prompt)) {
const value = prompt
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
.filter(Boolean)
.join("\n");
if (value) {
return value;
}
}
}
const result = envelope.result;
if (result && typeof result === "object") {
const text = (result as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
}
if (envelope.error) {
return JSON.stringify(envelope.error);
}
if (typeof envelope.method === "string") {
return envelope.method;
}
return JSON.stringify(payload);
}
function transcriptIncludesAgentText(
transcript: WorkbenchTranscriptEvent[],
expectedText: string,
): boolean {
return transcript
.filter((event) => event.sender === "agent")
.map((event) => extractEventText(event))
.join("")
.includes(expectedText);
}
describe("e2e(client): workbench flows", () => {
it.skipIf(!RUN_WORKBENCH_E2E)(
"creates a task, adds sessions, exchanges messages, and manages workbench state",
{ timeout: 20 * 60_000 },
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 = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const runId = `wb-${Date.now().toString(36)}`;
const expectedFile = `${runId}.txt`;
const expectedInitialReply = `WORKBENCH_READY_${runId}`;
const expectedReply = `WORKBENCH_ACK_${runId}`;
const client = createBackendClient({
endpoint,
defaultWorkspaceId: workspaceId,
});
const repo = await client.addRepo(workspaceId, repoRemote);
const created = await client.createWorkbenchTask(workspaceId, {
repoId: repo.repoId,
title: `Workbench E2E ${runId}`,
branch: `e2e/${runId}`,
model,
task: `Reply with exactly: ${expectedInitialReply}`,
});
const provisioned = await poll(
"task provisioning",
12 * 60_000,
2_000,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => task.branch === `e2e/${runId}` && task.tabs.length > 0,
);
const primaryTab = provisioned.tabs[0]!;
const initialCompleted = await poll(
"initial agent response",
12 * 60_000,
2_000,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, primaryTab.id);
return (
task.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, expectedInitialReply)
);
},
);
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
const detail = await client.getTask(workspaceId, created.taskId);
await seedSandboxFile(endpoint, detail, expectedFile, runId);
const fileSeeded = await poll(
"seeded sandbox file reflected in workbench",
30_000,
1_000,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => task.fileChanges.some((file) => file.path === expectedFile),
);
expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
await client.renameWorkbenchTask(workspaceId, {
taskId: created.taskId,
value: `Workbench E2E ${runId} Renamed`,
});
await client.renameWorkbenchSession(workspaceId, {
taskId: created.taskId,
tabId: primaryTab.id,
title: "Primary Session",
});
const secondTab = await client.createWorkbenchSession(workspaceId, {
taskId: created.taskId,
model,
});
await client.renameWorkbenchSession(workspaceId, {
taskId: created.taskId,
tabId: secondTab.tabId,
title: "Follow-up Session",
});
await client.updateWorkbenchDraft(workspaceId, {
taskId: created.taskId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [
{
id: `${expectedFile}:1`,
filePath: expectedFile,
lineNumber: 1,
lineContent: runId,
},
],
});
const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId);
expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply);
expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1);
await client.sendWorkbenchMessage(workspaceId, {
taskId: created.taskId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [],
});
const withSecondReply = await poll(
"follow-up session response",
10 * 60_000,
2_000,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, secondTab.tabId);
return (
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, expectedReply)
);
},
);
const secondTranscript = findTab(withSecondReply, secondTab.tabId).transcript;
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
await client.setWorkbenchSessionUnread(workspaceId, {
taskId: created.taskId,
tabId: secondTab.tabId,
unread: false,
});
await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId });
const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId);
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
await client.closeWorkbenchSession(workspaceId, {
taskId: created.taskId,
tabId: secondTab.tabId,
});
const closedSnapshot = await poll(
"secondary session closed",
30_000,
1_000,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => !task.tabs.some((tab) => tab.id === secondTab.tabId),
);
expect(closedSnapshot.tabs).toHaveLength(1);
await client.revertWorkbenchFile(workspaceId, {
taskId: created.taskId,
path: expectedFile,
});
const revertedSnapshot = await poll(
"file revert reflected in workbench",
30_000,
1_000,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
);
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);
expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`);
expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session");
},
);
});

View file

@ -0,0 +1,335 @@
import { describe, expect, it } from "vitest";
import type {
TaskWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchTask,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
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) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-4o":
case "o3":
return value;
default:
return fallback;
}
}
function intEnv(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) {
return fallback;
}
const value = Number.parseInt(raw, 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function poll<T>(
label: string,
timeoutMs: number,
intervalMs: number,
fn: () => Promise<T>,
isDone: (value: T) => boolean,
): Promise<T> {
const startedAt = Date.now();
let lastValue: T;
for (;;) {
lastValue = await fn();
if (isDone(lastValue)) {
return lastValue;
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`timed out waiting for ${label}`);
}
await sleep(intervalMs);
}
}
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
}
return task;
}
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from task ${task.id}`);
}
return tab;
}
function extractEventText(event: WorkbenchTranscriptEvent): string {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
}
const envelope = payload as {
method?: unknown;
params?: unknown;
result?: unknown;
};
const params = envelope.params;
if (params && typeof params === "object") {
const update = (params as { update?: unknown }).update;
if (update && typeof update === "object") {
const content = (update as { content?: unknown }).content;
if (content && typeof content === "object") {
const chunkText = (content as { text?: unknown }).text;
if (typeof chunkText === "string") {
return chunkText;
}
}
}
const text = (params as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt;
if (Array.isArray(prompt)) {
return prompt
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
.filter(Boolean)
.join("\n");
}
}
const result = envelope.result;
if (result && typeof result === "object") {
const text = (result as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
}
return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload);
}
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
return transcript
.filter((event) => event.sender === "agent")
.map((event) => extractEventText(event))
.join("")
.includes(expectedText);
}
function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
}
async function measureWorkbenchSnapshot(
client: ReturnType<typeof createBackendClient>,
workspaceId: string,
iterations: number,
): Promise<{
avgMs: number;
maxMs: number;
payloadBytes: number;
taskCount: number;
tabCount: number;
transcriptEventCount: number;
}> {
const durations: number[] = [];
let snapshot: TaskWorkbenchSnapshot | null = null;
for (let index = 0; index < iterations; index += 1) {
const startedAt = performance.now();
snapshot = await client.getWorkbench(workspaceId);
durations.push(performance.now() - startedAt);
}
const finalSnapshot = snapshot ?? {
workspaceId,
repos: [],
projects: [],
tasks: [],
};
const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8");
const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0);
const transcriptEventCount = finalSnapshot.tasks.reduce(
(sum, task) =>
sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
0,
);
return {
avgMs: Math.round(average(durations)),
maxMs: Math.round(Math.max(...durations, 0)),
payloadBytes,
taskCount: finalSnapshot.tasks.length,
tabCount,
transcriptEventCount,
};
}
describe("e2e(client): workbench load", () => {
it.skipIf(!RUN_WORKBENCH_LOAD_E2E)(
"runs a simple sequential load profile against the real backend",
{ timeout: 30 * 60_000 },
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 = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const taskCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
const client = createBackendClient({
endpoint,
defaultWorkspaceId: workspaceId,
});
const repo = await client.addRepo(workspaceId, repoRemote);
const createTaskLatencies: number[] = [];
const provisionLatencies: number[] = [];
const createSessionLatencies: number[] = [];
const messageRoundTripLatencies: number[] = [];
const snapshotSeries: Array<{
taskCount: number;
avgMs: number;
maxMs: number;
payloadBytes: number;
tabCount: number;
transcriptEventCount: number;
}> = [];
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
const initialReply = `LOAD_INIT_${runId}`;
const createStartedAt = performance.now();
const created = await client.createWorkbenchTask(workspaceId, {
repoId: repo.repoId,
title: `Workbench Load ${runId}`,
branch: `load/${runId}`,
model,
task: `Reply with exactly: ${initialReply}`,
});
createTaskLatencies.push(performance.now() - createStartedAt);
const provisionStartedAt = performance.now();
const provisioned = await poll(
`task ${runId} provisioning`,
12 * 60_000,
pollIntervalMs,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = task.tabs[0];
return Boolean(
tab &&
task.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, initialReply),
);
},
);
provisionLatencies.push(performance.now() - provisionStartedAt);
expect(provisioned.tabs.length).toBeGreaterThan(0);
const primaryTab = provisioned.tabs[0]!;
expect(transcriptIncludesAgentText(primaryTab.transcript, initialReply)).toBe(true);
for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) {
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
const createSessionStartedAt = performance.now();
const createdSession = await client.createWorkbenchSession(workspaceId, {
taskId: created.taskId,
model,
});
createSessionLatencies.push(performance.now() - createSessionStartedAt);
await client.sendWorkbenchMessage(workspaceId, {
taskId: created.taskId,
tabId: createdSession.tabId,
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
attachments: [],
});
const messageStartedAt = performance.now();
const withReply = await poll(
`task ${runId} session ${sessionIndex} reply`,
10 * 60_000,
pollIntervalMs,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, createdSession.tabId);
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
},
);
messageRoundTripLatencies.push(performance.now() - messageStartedAt);
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.tabId).transcript, expectedReply)).toBe(true);
}
const snapshotMetrics = await measureWorkbenchSnapshot(client, workspaceId, 3);
snapshotSeries.push(snapshotMetrics);
console.info(
"[workbench-load-snapshot]",
JSON.stringify({
taskIndex: taskIndex + 1,
...snapshotMetrics,
}),
);
}
const firstSnapshot = snapshotSeries[0]!;
const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!;
const summary = {
taskCount,
extraSessionCount,
createTaskAvgMs: Math.round(average(createTaskLatencies)),
provisionAvgMs: Math.round(average(provisionLatencies)),
createSessionAvgMs: Math.round(average(createSessionLatencies)),
messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)),
snapshotReadBaselineAvgMs: firstSnapshot.avgMs,
snapshotReadFinalAvgMs: lastSnapshot.avgMs,
snapshotReadFinalMaxMs: lastSnapshot.maxMs,
snapshotPayloadBaselineBytes: firstSnapshot.payloadBytes,
snapshotPayloadFinalBytes: lastSnapshot.payloadBytes,
snapshotTabFinalCount: lastSnapshot.tabCount,
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
};
console.info("[workbench-load-summary]", JSON.stringify(summary));
expect(createTaskLatencies.length).toBe(taskCount);
expect(provisionLatencies.length).toBe(taskCount);
expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount);
},
);
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
taskKey,
taskStatusSyncKey,
historyKey,
repoBranchSyncKey,
repoKey,
repoPrSyncKey,
sandboxInstanceKey,
workspaceKey
} from "../src/keys.js";
describe("actor keys", () => {
it("prefixes every key with workspace namespace", () => {
const keys = [
workspaceKey("default"),
repoKey("default", "repo"),
taskKey("default", "task"),
sandboxInstanceKey("default", "daytona", "sbx"),
historyKey("default", "repo"),
repoPrSyncKey("default", "repo"),
repoBranchSyncKey("default", "repo"),
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
];
for (const key of keys) {
expect(key[0]).toBe("ws");
expect(key[1]).toBe("default");
}
});
});

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import {
filterTasks,
formatRelativeAge,
fuzzyMatch,
summarizeTasks
} from "../src/view-model.js";
const sample: TaskRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
taskId: "task-1",
branchName: "feature/test",
title: "Test Title",
task: "Do test",
providerId: "daytona",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
sandboxes: [
{
sandboxId: "sandbox-1",
providerId: "daytona",
sandboxActorId: null,
switchTarget: "daytona://sandbox-1",
cwd: null,
createdAt: 1,
updatedAt: 1
}
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1
};
describe("search helpers", () => {
it("supports ordered fuzzy matching", () => {
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
});
it("filters rows across branch and title", () => {
const rows: TaskRecord[] = [
sample,
{
...sample,
taskId: "task-2",
branchName: "docs/update-intro",
title: "Docs Intro Refresh",
status: "idle"
}
];
expect(filterTasks(rows, "doc")).toHaveLength(1);
expect(filterTasks(rows, "h2")).toHaveLength(1);
expect(filterTasks(rows, "test")).toHaveLength(2);
});
});
describe("summary helpers", () => {
it("formats relative age", () => {
expect(formatRelativeAge(9_000, 10_000)).toBe("1s");
expect(formatRelativeAge(0, 120_000)).toBe("2m");
});
it("summarizes by status and provider", () => {
const rows: TaskRecord[] = [
sample,
{ ...sample, taskId: "task-2", status: "idle", providerId: "daytona" },
{ ...sample, taskId: "task-3", status: "error", providerId: "daytona" }
];
const summary = summarizeTasks(rows);
expect(summary.total).toBe(3);
expect(summary.byStatus.running).toBe(1);
expect(summary.byStatus.idle).toBe(1);
expect(summary.byStatus.error).toBe(1);
expect(summary.byProvider.daytona).toBe(3);
});
});

View file

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

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"]
}