Integrate OpenHandoff factory workspace (#212)

This commit is contained in:
Nathan Flurry 2026-03-09 14:00:20 -07:00 committed by GitHub
parent 3d9476ed0b
commit bf282199b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
251 changed files with 42824 additions and 692 deletions

View file

@ -0,0 +1,23 @@
{
"name": "@openhandoff/client",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.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": {
"@openhandoff/shared": "workspace:*",
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit"
},
"devDependencies": {
"tsup": "^8.5.0"
}
}

View file

@ -0,0 +1,821 @@
import { createClient } from "rivetkit/client";
import type {
AgentType,
AddRepoInput,
AppConfig,
CreateHandoffInput,
HandoffRecord,
HandoffSummary,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ProviderId,
RepoOverview,
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
SwitchResult
} from "@openhandoff/shared";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type HandoffAction = "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[]>;
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
listHandoffs(input: { workspaceId: string; repoId?: string }): Promise<HandoffSummary[]>;
getRepoOverview(input: { workspaceId: string; repoId: string }): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
history(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchHandoff(handoffId: string): Promise<SwitchResult>;
getHandoff(input: { workspaceId: string; handoffId: string }): Promise<HandoffRecord>;
attachHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
syncHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
mergeHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
archiveHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
killHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>;
getWorkbench(input: { workspaceId: string }): Promise<HandoffWorkbenchSnapshot>;
createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise<void>;
renameWorkbenchHandoff(input: HandoffWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>;
renameWorkbenchSession(input: HandoffWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void>;
updateWorkbenchDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(input: HandoffWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(input: HandoffWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(input: HandoffWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(input: HandoffWorkbenchDiffInput): 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 {
addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord>;
listRepos(workspaceId: string): Promise<RepoRecord[]>;
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]>;
getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord>;
listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchHandoff(workspaceId: string, handoffId: string): Promise<SwitchResult>;
attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(workspaceId: string, handoffId: string, action: HandoffAction): 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<HandoffWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchHandoff(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse>;
markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void>;
renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }>;
renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
): Promise<void>;
updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): 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;
const workbenchSubscriptions = new Map<
string,
{
listeners: Set<() => void>;
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
}
>();
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
});
// 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
? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500)
: true;
const resolvedEndpoint = 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 sandboxByActorIdFromHandoff = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle | null> => {
const ws = await workspace(workspaceId);
const rows = await ws.listHandoffs({ workspaceId });
const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
for (const row of candidates) {
try {
const detail = await ws.getHandoff({ workspaceId, handoffId: row.handoffId });
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 handoff")) {
throw error;
}
// Best effort fallback path; ignore missing handoff 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 sandboxByActorIdFromHandoff(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,
};
workbenchSubscriptions.set(workspaceId, entry);
}
entry.listeners.add(listener);
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);
void current.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
};
};
return {
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 createHandoff(input: CreateHandoffInput): Promise<HandoffRecord> {
return (await workspace(input.workspaceId)).createHandoff(input);
},
async listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]> {
return (await workspace(workspaceId)).listHandoffs({ 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 getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord> {
return (await workspace(workspaceId)).getHandoff({
workspaceId,
handoffId
});
},
async listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]> {
return (await workspace(input.workspaceId)).history(input);
},
async switchHandoff(workspaceId: string, handoffId: string): Promise<SwitchResult> {
return (await workspace(workspaceId)).switchHandoff(handoffId);
},
async attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> {
return (await workspace(workspaceId)).attachHandoff({
workspaceId,
handoffId,
reason: "cli.attach"
});
},
async runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise<void> {
if (action === "push") {
await (await workspace(workspaceId)).pushHandoff({
workspaceId,
handoffId,
reason: "cli.push"
});
return;
}
if (action === "sync") {
await (await workspace(workspaceId)).syncHandoff({
workspaceId,
handoffId,
reason: "cli.sync"
});
return;
}
if (action === "merge") {
await (await workspace(workspaceId)).mergeHandoff({
workspaceId,
handoffId,
reason: "cli.merge"
});
return;
}
if (action === "archive") {
await (await workspace(workspaceId)).archiveHandoff({
workspaceId,
handoffId,
reason: "cli.archive"
});
return;
}
await (await workspace(workspaceId)).killHandoff({
workspaceId,
handoffId,
reason: "cli.kill"
});
},
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<HandoffWorkbenchSnapshot> {
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
},
subscribeWorkbench(workspaceId: string, listener: () => void): () => void {
return subscribeWorkbench(workspaceId, listener);
},
async createWorkbenchHandoff(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse> {
return (await workspace(workspaceId)).createWorkbenchHandoff(input);
},
async markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).markWorkbenchUnread(input);
},
async renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchHandoff(input);
},
async renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchBranch(input);
},
async createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }> {
return await (await workspace(workspaceId)).createWorkbenchSession(input);
},
async renameWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchRenameSessionInput
): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
): Promise<void> {
await (await workspace(workspaceId)).setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(
workspaceId: string,
input: HandoffWorkbenchUpdateDraftInput
): Promise<void> {
await (await workspace(workspaceId)).updateWorkbenchDraft(input);
},
async changeWorkbenchModel(
workspaceId: string,
input: HandoffWorkbenchChangeModelInput
): Promise<void> {
await (await workspace(workspaceId)).changeWorkbenchModel(input);
},
async sendWorkbenchMessage(
workspaceId: string,
input: HandoffWorkbenchSendMessageInput
): Promise<void> {
await (await workspace(workspaceId)).sendWorkbenchMessage(input);
},
async stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).stopWorkbenchSession(input);
},
async closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).closeWorkbenchSession(input);
},
async publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).publishWorkbenchPr(input);
},
async revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): 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,4 @@
export * from "./backend-client.js";
export * from "./keys.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 projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
}
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
}
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, "project", repoId, "history"];
}
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "pr-sync"];
}
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "branch-sync"];
}
export function handoffStatusSyncKey(
workspaceId: string,
repoId: string,
handoffId: string,
sandboxId: string,
sessionId: string
): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
}

View file

@ -0,0 +1,445 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchProjects,
nowMs,
providerAgent,
randomReply,
removeFileTreePath,
slugify,
uid,
} from "../workbench-model.js";
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
WorkbenchAgentTab as AgentTab,
WorkbenchHandoff as Handoff,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
import type { HandoffWorkbenchClient } 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 HandoffWorkbenchClient {
private snapshot = buildInitialMockLayoutViewModel();
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): HandoffWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
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 handoff for unknown repo ${input.repoId}`);
}
const nextHandoff: Handoff = {
id,
repoId: repo.id,
title: input.title?.trim() || "New Handoff",
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,
handoffs: [nextHandoff, ...current.handoffs],
}));
return { handoffId: id, tabId };
}
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => {
const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null;
if (!targetTab) {
return handoff;
}
return {
...handoff,
tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
};
});
}
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`);
}
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() }));
}
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`);
}
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() }));
}
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() }));
}
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1;
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
}));
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => {
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...handoff.diffs };
delete nextDiffs[input.path];
return {
...handoff,
fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path),
diffs: nextDiffs,
fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree,
};
});
}
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
this.assertTab(input.handoffId, input.tabId);
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
updatedAtMs: nowMs(),
tabs: handoff.tabs.map((tab) =>
tab.id === input.tabId
? {
...tab,
draft: {
text: input.text,
attachments: input.attachments,
updatedAtMs: nowMs(),
},
}
: tab,
),
}));
}
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
const text = input.text.trim();
if (!text) {
throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`);
}
this.assertTab(input.handoffId, input.tabId);
const startedAtMs = nowMs();
this.updateHandoff(input.handoffId, (currentHandoff) => {
const isFirstOnHandoff = currentHandoff.status === "new";
const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title;
const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "client",
createdAt: startedAtMs,
eventIndex: candidateEventIndex(currentHandoff, input.tabId),
payload: {
method: "session/prompt",
params: {
prompt: userMessageLines.map((line) => ({ type: "text", text: line })),
},
},
});
return {
...currentHandoff,
title: newTitle,
branch: newBranch,
status: "running",
updatedAtMs: startedAtMs,
tabs: currentHandoff.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 handoff = this.requireHandoff(input.handoffId);
const replyTab = this.requireTab(handoff, input.tabId);
const completedAtMs = nowMs();
const replyEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "agent",
createdAt: completedAtMs,
eventIndex: candidateEventIndex(handoff, input.tabId),
payload: {
result: {
text: randomReply(),
durationMs: completedAtMs - startedAtMs,
},
},
});
this.updateHandoff(input.handoffId, (currentHandoff) => {
const updatedTabs = currentHandoff.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 {
...currentHandoff,
updatedAtMs: completedAtMs,
tabs: updatedTabs,
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
this.pendingTimers.delete(input.tabId);
}, 2_500);
this.pendingTimers.set(input.tabId, timer);
}
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
this.assertTab(input.handoffId, input.tabId);
const existing = this.pendingTimers.get(input.tabId);
if (existing) {
clearTimeout(existing);
this.pendingTimers.delete(input.tabId);
}
this.updateHandoff(input.handoffId, (currentHandoff) => {
const updatedTabs = currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
);
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentHandoff,
updatedAtMs: nowMs(),
tabs: updatedTabs,
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
}
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate,
),
}));
}
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
const title = input.title.trim();
if (!title) {
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
}
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate,
),
}));
}
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
this.updateHandoff(input.handoffId, (currentHandoff) => {
if (currentHandoff.tabs.length <= 1) {
return currentHandoff;
}
return {
...currentHandoff,
tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId),
};
});
}
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
this.assertHandoff(input.handoffId);
const nextTab: AgentTab = {
id: uid(),
sessionId: null,
sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
};
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
updatedAtMs: nowMs(),
tabs: [...currentHandoff.tabs, nextTab],
}));
return { tabId: nextTab.id };
}
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
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.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
),
}));
}
private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void {
const nextSnapshot = updater(this.snapshot);
this.snapshot = {
...nextSnapshot,
projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
};
this.notify();
}
private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void {
this.assertHandoff(handoffId);
this.updateState((current) => ({
...current,
handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)),
}));
}
private notify(): void {
for (const listener of this.listeners) {
listener();
}
}
private assertHandoff(handoffId: string): void {
this.requireHandoff(handoffId);
}
private assertTab(handoffId: string, tabId: string): void {
const handoff = this.requireHandoff(handoffId);
this.requireTab(handoff, tabId);
}
private requireHandoff(handoffId: string): Handoff {
const handoff = this.snapshot.handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`Unable to find mock handoff ${handoffId}`);
}
return handoff;
}
private requireTab(handoff: Handoff, tabId: string): AgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`);
}
return tab;
}
}
function candidateEventIndex(handoff: Handoff, tabId: string): number {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
return (tab?.transcript.length ?? 0) + 1;
}
let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null;
export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient {
if (!sharedMockWorkbenchClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore();
}
return sharedMockWorkbenchClient;
}

View file

@ -0,0 +1,199 @@
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchProjects } from "../workbench-model.js";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
workspaceId: string;
}
class RemoteWorkbenchStore implements HandoffWorkbenchClient {
private readonly backend: BackendClient;
private readonly workspaceId: string;
private snapshot: HandoffWorkbenchSnapshot;
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: [],
projects: [],
handoffs: [],
};
}
getSnapshot(): HandoffWorkbenchSnapshot {
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 createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
const created = await this.backend.createWorkbenchHandoff(this.workspaceId, input);
await this.refresh();
return created;
}
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.markWorkbenchUnread(this.workspaceId, input);
await this.refresh();
}
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchHandoff(this.workspaceId, input);
await this.refresh();
}
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchBranch(this.workspaceId, input);
await this.refresh();
}
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.handoffId, "archive");
await this.refresh();
}
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.workspaceId, input);
await this.refresh();
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();
}
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
await this.refresh();
}
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
await this.refresh();
}
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.workspaceId, input);
await this.refresh();
}
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
const created = await this.backend.createWorkbenchSession(this.workspaceId, input);
await this.refresh();
return created;
}
async changeModel(input: HandoffWorkbenchChangeModelInput): 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,
projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkbenchClient(
options: RemoteWorkbenchClientOptions,
): HandoffWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -0,0 +1,118 @@
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",
"running",
"idle",
"archived",
"killed",
"error"
] as const;
export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
const QUEUED_STATUSES = new Set<HandoffStatus>([
"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 groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup {
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<HandoffStatusGroup, number> {
return {
queued: 0,
running: 0,
idle: 0,
archived: 0,
killed: 0,
error: 0
};
}
export interface HandoffSummary {
total: number;
byStatus: Record<HandoffStatusGroup, 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 filterHandoffs(rows: HandoffRecord[], query: string): HandoffRecord[] {
const q = query.trim();
if (!q) {
return rows;
}
return rows.filter((row) => {
const fields = [
row.branchName ?? "",
row.title ?? "",
row.handoffId,
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 summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary {
const byStatus = emptyStatusCounts();
const byProvider: Record<string, number> = {};
for (const row of rows) {
byStatus[groupHandoffStatus(row.status)] += 1;
byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1;
}
return {
total: rows.length,
byStatus,
byProvider
};
}

View file

@ -0,0 +1,66 @@
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type HandoffWorkbenchClientMode = "mock" | "remote";
export interface CreateHandoffWorkbenchClientOptions {
mode: HandoffWorkbenchClientMode;
backend?: BackendClient;
workspaceId?: string;
}
export interface HandoffWorkbenchClient {
getSnapshot(): HandoffWorkbenchSnapshot;
subscribe(listener: () => void): () => void;
createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void>;
renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void>;
renameBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
publishPr(input: HandoffWorkbenchSelectInput): Promise<void>;
revertFile(input: HandoffWorkbenchDiffInput): Promise<void>;
updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: HandoffWorkbenchTabInput): Promise<void>;
setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void>;
closeTab(input: HandoffWorkbenchTabInput): Promise<void>;
addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse>;
changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
}
export function createHandoffWorkbenchClient(
options: CreateHandoffWorkbenchClientOptions,
): HandoffWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
}
if (!options.backend) {
throw new Error("Remote handoff workbench client requires a backend client");
}
if (!options.workspaceId) {
throw new Error("Remote handoff workbench client requires a workspace id");
}
return createRemoteWorkbenchClient({
backend: options.backend,
workspaceId: options.workspaceId,
});
}

View file

@ -0,0 +1,965 @@
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchAgentTab as AgentTab,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchHandoff as Handoff,
HandoffWorkbenchSnapshot,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection,
WorkbenchRepo,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "o3", label: "o3" },
],
},
];
const MOCK_REPLIES = [
"Got it. I'll work on that now. Let me start by examining the relevant files...",
"I've analyzed the codebase and found the relevant code. Making the changes now...",
"Working on it. I'll update you once I have the implementation ready.",
"Let me look into that. I'll trace through the code to understand the current behavior...",
"Starting on this now. I'll need to modify a few files to implement this properly.",
];
let nextId = 100;
export function uid(): string {
return String(++nextId);
}
export function nowMs(): number {
return Date.now();
}
export function formatThinkingDuration(durationMs: number): string {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function formatMessageDuration(durationMs: number): string {
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
}
export function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
}
export function randomReply(): string {
return MOCK_REPLIES[Math.floor(Math.random() * MOCK_REPLIES.length)]!;
}
const DIFF_PREFIX = "diff:";
export function isDiffTab(id: string): boolean {
return id.startsWith(DIFF_PREFIX);
}
export function diffPath(id: string): string {
return id.slice(DIFF_PREFIX.length);
}
export function diffTabId(path: string): string {
return `${DIFF_PREFIX}${path}`;
}
export function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function messageOrder(id: string): number {
const match = id.match(/\d+/);
return match ? Number(match[0]) : 0;
}
interface LegacyMessage {
id: string;
role: "agent" | "user";
agent: string | null;
createdAtMs: number;
lines: string[];
durationMs?: number;
}
function transcriptText(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
}
const envelope = payload as {
method?: unknown;
params?: unknown;
result?: unknown;
error?: unknown;
};
if (envelope.params && typeof envelope.params === "object") {
const prompt = (envelope.params as { prompt?: unknown }).prompt;
if (Array.isArray(prompt)) {
const text = prompt
.map((item) => (item && typeof item === "object" ? (item as { text?: unknown }).text : null))
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n");
if (text) {
return text;
}
}
const paramsText = (envelope.params as { text?: unknown }).text;
if (typeof paramsText === "string" && paramsText.trim().length > 0) {
return paramsText.trim();
}
}
if (envelope.result && typeof envelope.result === "object") {
const resultText = (envelope.result as { text?: unknown }).text;
if (typeof resultText === "string" && resultText.trim().length > 0) {
return resultText.trim();
}
}
if (envelope.error) {
return JSON.stringify(envelope.error);
}
if (typeof envelope.method === "string") {
return envelope.method;
}
return JSON.stringify(payload);
}
function historyPreview(event: TranscriptEvent): string {
const content = transcriptText(event.payload).trim() || "Untitled event";
return content.length > 42 ? `${content.slice(0, 39)}...` : content;
}
function historyDetail(event: TranscriptEvent): string {
const content = transcriptText(event.payload).trim();
return content || "Untitled event";
}
export function buildHistoryEvents(tabs: AgentTab[]): HistoryEvent[] {
return tabs
.flatMap((tab) =>
tab.transcript
.filter((event) => event.sender === "client")
.map((event) => ({
id: `history-${tab.id}-${event.id}`,
messageId: event.id,
preview: historyPreview(event),
sessionName: tab.sessionName,
tabId: tab.id,
createdAtMs: event.createdAt,
detail: historyDetail(event),
})),
)
.sort((left, right) => messageOrder(left.messageId) - messageOrder(right.messageId));
}
function transcriptFromLegacyMessages(sessionId: string, messages: LegacyMessage[]): TranscriptEvent[] {
return messages.map((message, index) => ({
id: message.id,
eventIndex: index + 1,
sessionId,
createdAt: message.createdAtMs,
connectionId: "mock-connection",
sender: message.role === "user" ? "client" : "agent",
payload:
message.role === "user"
? {
method: "session/prompt",
params: {
prompt: message.lines.map((line) => ({ type: "text", text: line })),
},
}
: {
result: {
text: message.lines.join("\n"),
durationMs: message.durationMs,
},
},
}));
}
const NOW_MS = Date.now();
function minutesAgo(minutes: number): number {
return NOW_MS - minutes * 60_000;
}
export function parseDiffLines(diff: string): ParsedDiffLine[] {
return diff.split("\n").map((text, index) => {
if (text.startsWith("@@")) {
return { kind: "hunk", lineNumber: index + 1, text };
}
if (text.startsWith("+")) {
return { kind: "add", lineNumber: index + 1, text };
}
if (text.startsWith("-")) {
return { kind: "remove", lineNumber: index + 1, text };
}
return { kind: "context", lineNumber: index + 1, text };
});
}
export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): FileTreeNode[] {
return nodes.flatMap((node) => {
if (node.path === targetPath) {
return [];
}
if (!node.children) {
return [node];
}
const nextChildren = removeFileTreePath(node.children, targetPath);
if (node.isDir && nextChildren.length === 0) {
return [];
}
return [{ ...node, children: nextChildren }];
});
}
export function buildInitialHandoffs(): Handoff[] {
return [
{
id: "h1",
repoId: "acme-backend",
title: "Fix auth token refresh",
status: "idle",
repoName: "acme/backend",
updatedAtMs: minutesAgo(2),
branch: "fix/auth-token-refresh",
pullRequest: { number: 47, status: "draft" },
tabs: [
{
id: "t1",
sessionId: "t1",
sessionName: "Auth token fix",
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t1", [
{
id: "m1",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(12),
lines: [
"I'll fix the auth token refresh logic. Let me start by examining the current implementation in `src/auth/token-manager.ts`.",
"",
"Found the issue - the refresh interval is set to 1 hour but the token expires in 5 minutes. Updating now.",
],
durationMs: 12_000,
},
{
id: "m2",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(11),
lines: [
"Fixed token refresh in `src/auth/token-manager.ts`. Also updated the retry logic in `src/api/client.ts` to handle 401 responses gracefully.",
],
durationMs: 18_000,
},
{
id: "m3",
role: "user",
agent: null,
createdAtMs: minutesAgo(10),
lines: ["Can you also add unit tests for the refresh logic?"],
},
{
id: "m4",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(9),
lines: ["Writing tests now in `src/auth/__tests__/token-manager.test.ts`..."],
durationMs: 9_000,
},
]),
},
{
id: "t2",
sessionId: "t2",
sessionName: "Code analysis",
agent: "Codex",
model: "gpt-4o",
status: "idle",
thinkingSinceMs: null,
unread: true,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t2", [
{
id: "m5",
role: "agent",
agent: "codex",
createdAtMs: minutesAgo(15),
lines: ["Analyzed the codebase. The auth module uses a simple in-memory token cache with no refresh mechanism."],
durationMs: 21_000,
},
{
id: "m6",
role: "agent",
agent: "codex",
createdAtMs: minutesAgo(14),
lines: ["Suggested approach: add a refresh timer that fires before token expiry. I'll wait for instructions."],
durationMs: 7_000,
},
]),
},
],
fileChanges: [
{ path: "src/auth/token-manager.ts", added: 18, removed: 5, type: "M" },
{ path: "src/api/client.ts", added: 8, removed: 3, type: "M" },
{ path: "src/auth/__tests__/token-manager.test.ts", added: 21, removed: 0, type: "A" },
{ path: "src/types/auth.ts", added: 0, removed: 4, type: "M" },
],
diffs: {
"src/auth/token-manager.ts": [
"@@ -21,10 +21,15 @@ import { TokenCache } from './cache';",
" export class TokenManager {",
" private refreshInterval: number;",
" ",
"- const REFRESH_MS = 3_600_000; // 1 hour",
"+ const REFRESH_MS = 300_000; // 5 minutes",
" ",
"+ async refreshToken(): Promise<string> {",
"+ const newToken = await this.fetchNewToken();",
"+ this.cache.set(newToken);",
"+ return newToken;",
"+ }",
" ",
"- private async onExpiry() {",
"- console.log('token expired');",
"- this.logout();",
"- }",
"+ private async onExpiry() {",
"+ try {",
"+ await this.refreshToken();",
"+ } catch { this.logout(); }",
"+ }",
].join("\n"),
"src/api/client.ts": [
"@@ -45,8 +45,16 @@ export class ApiClient {",
" private async request<T>(url: string, opts?: RequestInit): Promise<T> {",
" const token = await this.tokenManager.getToken();",
" const res = await fetch(url, {",
"- ...opts,",
"- headers: { Authorization: `Bearer ${token}` },",
"+ ...opts, headers: {",
"+ ...opts?.headers,",
"+ Authorization: `Bearer ${token}`,",
"+ },",
" });",
"- return res.json();",
"+ if (res.status === 401) {",
"+ const freshToken = await this.tokenManager.refreshToken();",
"+ const retry = await fetch(url, {",
"+ ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${freshToken}` },",
"+ });",
"+ return retry.json();",
"+ }",
"+ return res.json() as T;",
" }",
].join("\n"),
"src/auth/__tests__/token-manager.test.ts": [
"@@ -0,0 +1,21 @@",
"+import { describe, it, expect, vi } from 'vitest';",
"+import { TokenManager } from '../token-manager';",
"+",
"+describe('TokenManager', () => {",
"+ it('refreshes token before expiry', async () => {",
"+ const mgr = new TokenManager({ expiresIn: 100 });",
"+ const first = await mgr.getToken();",
"+ await new Promise(r => setTimeout(r, 150));",
"+ const second = await mgr.getToken();",
"+ expect(second).not.toBe(first);",
"+ });",
"+",
"+ it('retries on 401', async () => {",
"+ const mgr = new TokenManager();",
"+ const spy = vi.spyOn(mgr, 'refreshToken');",
"+ await mgr.handleUnauthorized();",
"+ expect(spy).toHaveBeenCalledOnce();",
"+ });",
"+",
"+ it('logs out after max retries', async () => {",
"+ const mgr = new TokenManager({ maxRetries: 0 });",
"+ await expect(mgr.handleUnauthorized()).rejects.toThrow();",
"+ });",
"+});",
].join("\n"),
"src/types/auth.ts": [
"@@ -8,10 +8,6 @@ export interface AuthConfig {",
" clientId: string;",
" clientSecret: string;",
" redirectUri: string;",
"- /** @deprecated Use refreshInterval instead */",
"- legacyTimeout?: number;",
"- /** @deprecated */",
"- useOldRefresh?: boolean;",
" }",
" ",
" export interface TokenPayload {",
].join("\n"),
},
fileTree: [
{
name: "src",
path: "src",
isDir: true,
children: [
{
name: "api",
path: "src/api",
isDir: true,
children: [{ name: "client.ts", path: "src/api/client.ts", isDir: false }],
},
{
name: "auth",
path: "src/auth",
isDir: true,
children: [
{
name: "__tests__",
path: "src/auth/__tests__",
isDir: true,
children: [{ name: "token-manager.test.ts", path: "src/auth/__tests__/token-manager.test.ts", isDir: false }],
},
{ name: "token-manager.ts", path: "src/auth/token-manager.ts", isDir: false },
],
},
{
name: "types",
path: "src/types",
isDir: true,
children: [{ name: "auth.ts", path: "src/types/auth.ts", isDir: false }],
},
],
},
],
},
{
id: "h3",
repoId: "acme-backend",
title: "Refactor user service",
status: "idle",
repoName: "acme/backend",
updatedAtMs: minutesAgo(5),
branch: "refactor/user-service",
pullRequest: { number: 52, status: "ready" },
tabs: [
{
id: "t4",
sessionId: "t4",
sessionName: "DI refactor",
agent: "Claude",
model: "claude-opus-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t4", [
{
id: "m20",
role: "user",
agent: null,
createdAtMs: minutesAgo(35),
lines: ["Refactor the user service to use dependency injection."],
},
{
id: "m21",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(34),
lines: ["Starting refactor. I'll extract interfaces and set up a DI container..."],
durationMs: 14_000,
},
]),
},
],
fileChanges: [
{ path: "src/services/user-service.ts", added: 45, removed: 30, type: "M" },
{ path: "src/services/interfaces.ts", added: 22, removed: 0, type: "A" },
{ path: "src/container.ts", added: 15, removed: 0, type: "A" },
],
diffs: {
"src/services/user-service.ts": [
"@@ -1,35 +1,50 @@",
"-import { db } from '../db';",
"-import { hashPassword, verifyPassword } from '../utils/crypto';",
"-import { sendEmail } from '../utils/email';",
"+import type { IUserRepository, IEmailService, IHashService } from './interfaces';",
" ",
"-export class UserService {",
"- async createUser(email: string, password: string) {",
"- const hash = await hashPassword(password);",
"- const user = await db.users.create({",
"- email,",
"- passwordHash: hash,",
"- createdAt: new Date(),",
"- });",
"- await sendEmail(email, 'Welcome!', 'Thanks for signing up.');",
"- return user;",
"+export class UserService {",
"+ constructor(",
"+ private readonly users: IUserRepository,",
"+ private readonly email: IEmailService,",
"+ private readonly hash: IHashService,",
"+ ) {}",
"+",
"+ async createUser(email: string, password: string) {",
"+ const passwordHash = await this.hash.hash(password);",
"+ const user = await this.users.create({ email, passwordHash });",
"+ await this.email.send(email, 'Welcome!', 'Thanks for signing up.');",
"+ return user;",
" }",
" ",
"- async authenticate(email: string, password: string) {",
"- const user = await db.users.findByEmail(email);",
"+ async authenticate(email: string, password: string) {",
"+ const user = await this.users.findByEmail(email);",
" if (!user) throw new Error('User not found');",
"- const valid = await verifyPassword(password, user.passwordHash);",
"+ const valid = await this.hash.verify(password, user.passwordHash);",
" if (!valid) throw new Error('Invalid password');",
" return user;",
" }",
" ",
"- async deleteUser(id: string) {",
"- await db.users.delete(id);",
"+ async deleteUser(id: string) {",
"+ const user = await this.users.findById(id);",
"+ if (!user) throw new Error('User not found');",
"+ await this.users.delete(id);",
"+ await this.email.send(user.email, 'Account deleted', 'Your account has been removed.');",
" }",
" }",
].join("\n"),
"src/services/interfaces.ts": [
"@@ -0,0 +1,22 @@",
"+export interface IUserRepository {",
"+ create(data: { email: string; passwordHash: string }): Promise<User>;",
"+ findByEmail(email: string): Promise<User | null>;",
"+ findById(id: string): Promise<User | null>;",
"+ delete(id: string): Promise<void>;",
"+}",
"+",
"+export interface IEmailService {",
"+ send(to: string, subject: string, body: string): Promise<void>;",
"+}",
"+",
"+export interface IHashService {",
"+ hash(password: string): Promise<string>;",
"+ verify(password: string, hash: string): Promise<boolean>;",
"+}",
"+",
"+export interface User {",
"+ id: string;",
"+ email: string;",
"+ passwordHash: string;",
"+ createdAt: Date;",
"+}",
].join("\n"),
"src/container.ts": [
"@@ -0,0 +1,15 @@",
"+import { UserService } from './services/user-service';",
"+import { DrizzleUserRepository } from './repos/user-repo';",
"+import { ResendEmailService } from './providers/email';",
"+import { Argon2HashService } from './providers/hash';",
"+import { db } from './db';",
"+",
"+const userRepo = new DrizzleUserRepository(db);",
"+const emailService = new ResendEmailService();",
"+const hashService = new Argon2HashService();",
"+",
"+export const userService = new UserService(",
"+ userRepo,",
"+ emailService,",
"+ hashService,",
"+);",
].join("\n"),
},
fileTree: [
{
name: "src",
path: "src",
isDir: true,
children: [
{ name: "container.ts", path: "src/container.ts", isDir: false },
{
name: "services",
path: "src/services",
isDir: true,
children: [
{ name: "interfaces.ts", path: "src/services/interfaces.ts", isDir: false },
{ name: "user-service.ts", path: "src/services/user-service.ts", isDir: false },
],
},
],
},
],
},
{
id: "h2",
repoId: "acme-frontend",
title: "Add dark mode toggle",
status: "idle",
repoName: "acme/frontend",
updatedAtMs: minutesAgo(15),
branch: "feat/dark-mode",
pullRequest: null,
tabs: [
{
id: "t3",
sessionId: "t3",
sessionName: "Dark mode",
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: true,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t3", [
{
id: "m10",
role: "user",
agent: null,
createdAtMs: minutesAgo(75),
lines: ["Add a dark mode toggle to the settings page."],
},
{
id: "m11",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(74),
lines: [
"I've added a dark mode toggle to the settings page. The implementation uses CSS custom properties for theming and persists the user's preference to localStorage.",
],
durationMs: 26_000,
},
]),
},
],
fileChanges: [
{ path: "src/components/settings.tsx", added: 32, removed: 2, type: "M" },
{ path: "src/styles/theme.css", added: 45, removed: 0, type: "A" },
],
diffs: {
"src/components/settings.tsx": [
"@@ -1,5 +1,6 @@",
" import React, { useState, useEffect } from 'react';",
"+import { useTheme } from '../hooks/use-theme';",
" import { Card } from './ui/card';",
" import { Toggle } from './ui/toggle';",
" import { Label } from './ui/label';",
"@@ -15,8 +16,38 @@ export function Settings() {",
" const [notifications, setNotifications] = useState(true);",
"+ const { theme, setTheme } = useTheme();",
"+ const [isDark, setIsDark] = useState(theme === 'dark');",
" ",
" return (",
" <div className=\"settings-page\">",
"+ <Card>",
"+ <h3>Appearance</h3>",
"+ <div className=\"setting-row\">",
"+ <Label htmlFor=\"dark-mode\">Dark Mode</Label>",
"+ <Toggle",
"+ id=\"dark-mode\"",
"+ checked={isDark}",
"+ onCheckedChange={(checked) => {",
"+ setIsDark(checked);",
"+ setTheme(checked ? 'dark' : 'light');",
"+ document.documentElement.setAttribute(",
"+ 'data-theme',",
"+ checked ? 'dark' : 'light'",
"+ );",
"+ }}",
"+ />",
"+ </div>",
"+ <p className=\"setting-description\">",
"+ Toggle between light and dark color schemes.",
"+ Your preference is saved to localStorage.",
"+ </p>",
"+ </Card>",
"+",
" <Card>",
" <h3>Notifications</h3>",
" <div className=\"setting-row\">",
"- <Label>Email notifications</Label>",
"- <Toggle checked={notifications} onCheckedChange={setNotifications} />",
"+ <Label htmlFor=\"notifs\">Email notifications</Label>",
"+ <Toggle id=\"notifs\" checked={notifications} onCheckedChange={setNotifications} />",
" </div>",
" </Card>",
].join("\n"),
"src/styles/theme.css": [
"@@ -0,0 +1,45 @@",
"+:root {",
"+ --bg-primary: #ffffff;",
"+ --bg-secondary: #f8f9fa;",
"+ --bg-tertiary: #e9ecef;",
"+ --text-primary: #212529;",
"+ --text-secondary: #495057;",
"+ --text-muted: #868e96;",
"+ --border-color: #dee2e6;",
"+ --accent: #228be6;",
"+ --accent-hover: #1c7ed6;",
"+}",
"+",
"+[data-theme='dark'] {",
"+ --bg-primary: #09090b;",
"+ --bg-secondary: #18181b;",
"+ --bg-tertiary: #27272a;",
"+ --text-primary: #fafafa;",
"+ --text-secondary: #a1a1aa;",
"+ --text-muted: #71717a;",
"+ --border-color: #3f3f46;",
"+ --accent: #ff4f00;",
"+ --accent-hover: #ff6a00;",
"+}",
"+",
"+body {",
"+ background: var(--bg-primary);",
"+ color: var(--text-primary);",
"+ transition: background 0.2s ease, color 0.2s ease;",
"+}",
"+",
"+.card {",
"+ background: var(--bg-secondary);",
"+ border: 1px solid var(--border-color);",
"+ border-radius: 8px;",
"+ padding: 16px 20px;",
"+}",
"+",
"+.setting-row {",
"+ display: flex;",
"+ align-items: center;",
"+ justify-content: space-between;",
"+ padding: 8px 0;",
"+}",
"+",
"+.setting-description {",
"+ color: var(--text-muted);",
"+ font-size: 13px;",
"+ margin-top: 4px;",
"+}",
].join("\n"),
},
fileTree: [
{
name: "src",
path: "src",
isDir: true,
children: [
{
name: "components",
path: "src/components",
isDir: true,
children: [{ name: "settings.tsx", path: "src/components/settings.tsx", isDir: false }],
},
{
name: "styles",
path: "src/styles",
isDir: true,
children: [{ name: "theme.css", path: "src/styles/theme.css", isDir: false }],
},
],
},
],
},
{
id: "h5",
repoId: "acme-infra",
title: "Update CI pipeline",
status: "archived",
repoName: "acme/infra",
updatedAtMs: minutesAgo(2 * 24 * 60 + 10),
branch: "chore/ci-pipeline",
pullRequest: { number: 38, status: "ready" },
tabs: [
{
id: "t6",
sessionId: "t6",
sessionName: "CI pipeline",
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t6", [
{
id: "m30",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(2 * 24 * 60 + 60),
lines: ["CI pipeline updated. Added caching for node_modules and parallel test execution."],
durationMs: 33_000,
},
]),
},
],
fileChanges: [{ path: ".github/workflows/ci.yml", added: 20, removed: 8, type: "M" }],
diffs: {
".github/workflows/ci.yml": [
"@@ -12,14 +12,26 @@ jobs:",
" build:",
" runs-on: ubuntu-latest",
" steps:",
"- - uses: actions/checkout@v3",
"- - uses: actions/setup-node@v3",
"+ - uses: actions/checkout@v4",
"+ - uses: actions/setup-node@v4",
" with:",
" node-version: 20",
"- - run: npm ci",
"- - run: npm run build",
"- - run: npm test",
"+ cache: 'pnpm'",
"+ - uses: pnpm/action-setup@v4",
"+ - run: pnpm install --frozen-lockfile",
"+ - run: pnpm build",
"+",
"+ test:",
"+ runs-on: ubuntu-latest",
"+ needs: build",
"+ strategy:",
"+ matrix:",
"+ shard: [1, 2, 3]",
"+ steps:",
"+ - uses: actions/checkout@v4",
"+ - uses: actions/setup-node@v4",
"+ with:",
"+ node-version: 20",
"+ cache: 'pnpm'",
"+ - uses: pnpm/action-setup@v4",
"+ - run: pnpm install --frozen-lockfile",
"+ - run: pnpm test -- --shard=${{ matrix.shard }}/3",
" ",
"- deploy:",
"- needs: build",
"- if: github.ref == 'refs/heads/main'",
"+ deploy:",
"+ needs: [build, test]",
"+ if: github.ref == 'refs/heads/main'",
].join("\n"),
},
fileTree: [
{
name: ".github",
path: ".github",
isDir: true,
children: [
{
name: "workflows",
path: ".github/workflows",
isDir: true,
children: [{ name: "ci.yml", path: ".github/workflows/ci.yml", isDir: false }],
},
],
},
],
},
];
}
export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
const repos: WorkbenchRepo[] = [
{ id: "acme-backend", label: "acme/backend" },
{ id: "acme-frontend", label: "acme/frontend" },
{ id: "acme-infra", label: "acme/infra" },
];
const handoffs = buildInitialHandoffs();
return {
workspaceId: "default",
repos,
projects: groupWorkbenchProjects(repos, handoffs),
handoffs,
};
}
export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff[]): WorkbenchProjectSection[] {
const grouped = new Map<string, WorkbenchProjectSection>();
for (const repo of repos) {
grouped.set(repo.id, {
id: repo.id,
label: repo.label,
updatedAtMs: 0,
handoffs: [],
});
}
for (const handoff of handoffs) {
const existing = grouped.get(handoff.repoId) ?? {
id: handoff.repoId,
label: handoff.repoName,
updatedAtMs: 0,
handoffs: [],
};
existing.handoffs.push(handoff);
existing.updatedAtMs = Math.max(existing.updatedAtMs, handoff.updatedAtMs);
grouped.set(handoff.repoId, existing);
}
return [...grouped.values()]
.map((project) => ({
...project,
handoffs: [...project.handoffs].sort((a, b) => b.updatedAtMs - a.updatedAtMs),
updatedAtMs:
project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs,
}))
.filter((project) => project.handoffs.length > 0)
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
}

View file

@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { HistoryEvent, RepoOverview } from "@openhandoff/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 { HandoffRecord, HistoryEvent } from "@openhandoff/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, handoffId: string): Promise<string> {
try {
const handoff = await client.getHandoff(workspaceId, handoffId);
const history = await client.listHistory({ workspaceId, handoffId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`)
.join("\n");
let sessionEventsSummary = "";
if (handoff.activeSandboxId && handoff.activeSessionId) {
const events = await client
.listSandboxSessionEvents(workspaceId, handoff.providerId, handoff.activeSandboxId, {
sessionId: handoff.activeSessionId,
limit: 50,
})
.then((r) => r.items)
.catch(() => []);
sessionEventsSummary = events
.slice(-12)
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.sender}`)
.join("\n");
}
return [
"=== handoff ===",
JSON.stringify(
{
status: handoff.status,
statusMessage: handoff.statusMessage,
title: handoff.title,
branchName: handoff.branchName,
activeSandboxId: handoff.activeSandboxId,
activeSessionId: handoff.activeSessionId,
prUrl: handoff.prUrl,
prSubmitted: handoff.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 handoff, 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.createHandoff({
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<HandoffRecord>(
"handoff naming + sandbox provisioning",
// Cold Daytona snapshot/image preparation can exceed 5 minutes on first run.
8 * 60_000,
1_000,
async () => client.getHandoff(workspaceId, created.handoffId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
lastStatus = h.status;
}
if (h.status === "error") {
throw new Error("handoff entered error state during provisioning");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<HandoffRecord>(
"handoff to create active session",
3 * 60_000,
1_500,
async () => client.getHandoff(workspaceId, created.handoffId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
throw new Error("handoff entered error state while waiting for active session");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
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.handoffId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
await poll<HandoffRecord>(
"handoff to reach idle state",
8 * 60_000,
2_000,
async () => client.getHandoff(workspaceId, created.handoffId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
throw new Error("handoff entered error state while waiting for idle");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
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, handoffId: created.handoffId, limit: 200 }),
(events) => events.some((e) => e.kind === "handoff.pr_created")
)
.catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "handoff.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 handoff and assert the sandbox is released (stopped).
await client.runAction(workspaceId, created.handoffId, "archive");
await poll<HandoffRecord>(
"handoff to become archived (session released)",
60_000,
1_000,
async () => client.getHandoff(workspaceId, created.handoffId),
(h) => h.status === "archived" && h.activeSessionId === null
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
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.handoffId);
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,334 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import type {
HandoffWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/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 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));
}
async function seedSandboxFile(workspaceId: string, handoffId: string, filePath: string, content: string): Promise<void> {
const repoPath = `/root/.local/share/openhandoff/local-sandboxes/${workspaceId}/${handoffId}/repo`;
const script = [
`cd ${JSON.stringify(repoPath)}`,
`mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`,
`printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`,
].join(" && ");
await execFileAsync("docker", ["exec", "openhandoff-backend-1", "bash", "-lc", script]);
}
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 findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff {
const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`handoff ${handoffId} missing from snapshot`);
}
return handoff;
}
function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from handoff ${handoff.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 handoff, 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 = requiredEnv("HF_E2E_GITHUB_REPO");
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.createWorkbenchHandoff(workspaceId, {
repoId: repo.repoId,
title: `Workbench E2E ${runId}`,
branch: `e2e/${runId}`,
model,
task: `Reply with exactly: ${expectedInitialReply}`,
});
const provisioned = await poll(
"handoff provisioning",
12 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => handoff.branch === `e2e/${runId}` && handoff.tabs.length > 0,
);
const primaryTab = provisioned.tabs[0]!;
const initialCompleted = await poll(
"initial agent response",
12 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, primaryTab.id);
return (
handoff.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);
await seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId);
const fileSeeded = await poll(
"seeded sandbox file reflected in workbench",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => handoff.fileChanges.some((file) => file.path === expectedFile),
);
expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
await client.renameWorkbenchHandoff(workspaceId, {
handoffId: created.handoffId,
value: `Workbench E2E ${runId} Renamed`,
});
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
tabId: primaryTab.id,
title: "Primary Session",
});
const secondTab = await client.createWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
model,
});
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
title: "Follow-up Session",
});
await client.updateWorkbenchDraft(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [
{
id: `${expectedFile}:1`,
filePath: expectedFile,
lineNumber: 1,
lineContent: runId,
},
],
});
const drafted = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply);
expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [],
});
const withSecondReply = await poll(
"follow-up session response",
10 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, 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, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
unread: false,
});
await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId });
const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
await client.closeWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
});
const closedSnapshot = await poll(
"secondary session closed",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => !handoff.tabs.some((tab) => tab.id === secondTab.tabId),
);
expect(closedSnapshot.tabs).toHaveLength(1);
await client.revertWorkbenchFile(workspaceId, {
handoffId: created.handoffId,
path: expectedFile,
});
const revertedSnapshot = await poll(
"file revert reflected in workbench",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => !handoff.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,331 @@
import { describe, expect, it } from "vitest";
import type {
HandoffWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/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 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 findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff {
const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`handoff ${handoffId} missing from snapshot`);
}
return handoff;
}
function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from handoff ${handoff.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;
handoffCount: number;
tabCount: number;
transcriptEventCount: number;
}> {
const durations: number[] = [];
let snapshot: HandoffWorkbenchSnapshot | 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: [],
handoffs: [],
};
const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8");
const tabCount = finalSnapshot.handoffs.reduce((sum, handoff) => sum + handoff.tabs.length, 0);
const transcriptEventCount = finalSnapshot.handoffs.reduce(
(sum, handoff) =>
sum + handoff.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
0,
);
return {
avgMs: Math.round(average(durations)),
maxMs: Math.round(Math.max(...durations, 0)),
payloadBytes,
handoffCount: finalSnapshot.handoffs.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 = requiredEnv("HF_E2E_GITHUB_REPO");
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
const client = createBackendClient({
endpoint,
defaultWorkspaceId: workspaceId,
});
const repo = await client.addRepo(workspaceId, repoRemote);
const createHandoffLatencies: number[] = [];
const provisionLatencies: number[] = [];
const createSessionLatencies: number[] = [];
const messageRoundTripLatencies: number[] = [];
const snapshotSeries: Array<{
handoffCount: number;
avgMs: number;
maxMs: number;
payloadBytes: number;
tabCount: number;
transcriptEventCount: number;
}> = [];
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
for (let handoffIndex = 0; handoffIndex < handoffCount; handoffIndex += 1) {
const runId = `load-${handoffIndex}-${Date.now().toString(36)}`;
const initialReply = `LOAD_INIT_${runId}`;
const createStartedAt = performance.now();
const created = await client.createWorkbenchHandoff(workspaceId, {
repoId: repo.repoId,
title: `Workbench Load ${runId}`,
branch: `load/${runId}`,
model,
task: `Reply with exactly: ${initialReply}`,
});
createHandoffLatencies.push(performance.now() - createStartedAt);
const provisionStartedAt = performance.now();
const provisioned = await poll(
`handoff ${runId} provisioning`,
12 * 60_000,
pollIntervalMs,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = handoff.tabs[0];
return Boolean(
tab &&
handoff.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, {
handoffId: created.handoffId,
model,
});
createSessionLatencies.push(performance.now() - createSessionStartedAt);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
tabId: createdSession.tabId,
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
attachments: [],
});
const messageStartedAt = performance.now();
const withReply = await poll(
`handoff ${runId} session ${sessionIndex} reply`,
10 * 60_000,
pollIntervalMs,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, 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({
handoffIndex: handoffIndex + 1,
...snapshotMetrics,
}),
);
}
const firstSnapshot = snapshotSeries[0]!;
const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!;
const summary = {
handoffCount,
extraSessionCount,
createHandoffAvgMs: Math.round(average(createHandoffLatencies)),
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(createHandoffLatencies.length).toBe(handoffCount);
expect(provisionLatencies.length).toBe(handoffCount);
expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount);
},
);
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
handoffKey,
handoffStatusSyncKey,
historyKey,
projectBranchSyncKey,
projectKey,
projectPrSyncKey,
sandboxInstanceKey,
workspaceKey
} from "../src/keys.js";
describe("actor keys", () => {
it("prefixes every key with workspace namespace", () => {
const keys = [
workspaceKey("default"),
projectKey("default", "repo"),
handoffKey("default", "repo", "handoff"),
sandboxInstanceKey("default", "daytona", "sbx"),
historyKey("default", "repo"),
projectPrSyncKey("default", "repo"),
projectBranchSyncKey("default", "repo"),
handoffStatusSyncKey("default", "repo", "handoff", "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 { HandoffRecord } from "@openhandoff/shared";
import {
filterHandoffs,
formatRelativeAge,
fuzzyMatch,
summarizeHandoffs
} from "../src/view-model.js";
const sample: HandoffRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
handoffId: "handoff-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: HandoffRecord[] = [
sample,
{
...sample,
handoffId: "handoff-2",
branchName: "docs/update-intro",
title: "Docs Intro Refresh",
status: "idle"
}
];
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
expect(filterHandoffs(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: HandoffRecord[] = [
sample,
{ ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" },
{ ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" }
];
const summary = summarizeHandoffs(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,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"]
}