mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 21:02:09 +00:00
Integrate OpenHandoff factory workspace (#212)
This commit is contained in:
parent
3d9476ed0b
commit
bf282199b5
251 changed files with 42824 additions and 692 deletions
23
factory/packages/client/package.json
Normal file
23
factory/packages/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
821
factory/packages/client/src/backend-client.ts
Normal file
821
factory/packages/client/src/backend-client.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
4
factory/packages/client/src/index.ts
Normal file
4
factory/packages/client/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./backend-client.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./view-model.js";
|
||||
export * from "./workbench-client.js";
|
||||
44
factory/packages/client/src/keys.ts
Normal file
44
factory/packages/client/src/keys.ts
Normal 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];
|
||||
}
|
||||
445
factory/packages/client/src/mock/workbench-client.ts
Normal file
445
factory/packages/client/src/mock/workbench-client.ts
Normal 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;
|
||||
}
|
||||
199
factory/packages/client/src/remote/workbench-client.ts
Normal file
199
factory/packages/client/src/remote/workbench-client.ts
Normal 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);
|
||||
}
|
||||
118
factory/packages/client/src/view-model.ts
Normal file
118
factory/packages/client/src/view-model.ts
Normal 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
|
||||
};
|
||||
}
|
||||
66
factory/packages/client/src/workbench-client.ts
Normal file
66
factory/packages/client/src/workbench-client.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
965
factory/packages/client/src/workbench-model.ts
Normal file
965
factory/packages/client/src/workbench-model.ts
Normal 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);
|
||||
}
|
||||
197
factory/packages/client/test/e2e/full-integration-e2e.test.ts
Normal file
197
factory/packages/client/test/e2e/full-integration-e2e.test.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
351
factory/packages/client/test/e2e/github-pr-e2e.test.ts
Normal file
351
factory/packages/client/test/e2e/github-pr-e2e.test.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
334
factory/packages/client/test/e2e/workbench-e2e.test.ts
Normal file
334
factory/packages/client/test/e2e/workbench-e2e.test.ts
Normal 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");
|
||||
},
|
||||
);
|
||||
});
|
||||
331
factory/packages/client/test/e2e/workbench-load-e2e.test.ts
Normal file
331
factory/packages/client/test/e2e/workbench-load-e2e.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
31
factory/packages/client/test/keys.test.ts
Normal file
31
factory/packages/client/test/keys.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
92
factory/packages/client/test/view-model.test.ts
Normal file
92
factory/packages/client/test/view-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
7
factory/packages/client/tsconfig.json
Normal file
7
factory/packages/client/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue