Merge remote-tracking branch 'origin/main' into foundry-terminal-pane

# Conflicts:
#	factory/packages/backend/src/driver.ts
#	factory/packages/backend/src/integrations/sandbox-agent/client.ts
#	factory/packages/backend/test/helpers/test-driver.ts
#	factory/packages/frontend/src/components/mock-layout.tsx
#	pnpm-lock.yaml
#	sdks/react/src/ProcessTerminal.tsx
This commit is contained in:
Nathan Flurry 2026-03-10 23:59:58 -07:00
commit b00c0109d0
288 changed files with 7048 additions and 9134 deletions

View file

@ -25,7 +25,9 @@ import type {
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
SwitchResult
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
} from "@openhandoff/shared";
import type {
ProcessCreateRequest,
@ -86,6 +88,7 @@ interface WorkspaceHandle {
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 }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getWorkbench(input: { workspaceId: string }): Promise<HandoffWorkbenchSnapshot>;
createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise<void>;
@ -104,7 +107,11 @@ interface WorkspaceHandle {
}
interface SandboxInstanceHandle {
createSession(input: { prompt: string; cwd?: string; agent?: AgentType | "opencode" }): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
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 }>;
createProcess(input: ProcessCreateRequest): Promise<SandboxProcessRecord>;
@ -166,13 +173,13 @@ export interface BackendClient {
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input?: { cursor?: string; limit?: number }
input?: { cursor?: string; limit?: number },
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
listSandboxSessionEvents(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input: { sessionId: string; cursor?: string; limit?: number }
input: { sessionId: string; cursor?: string; limit?: number },
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
createSandboxProcess(input: {
workspaceId: string;
@ -230,12 +237,12 @@ export interface BackendClient {
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
sessionId: string
sessionId: string,
): Promise<{ id: string; status: "running" | "idle" | "error" }>;
sandboxProviderState(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
sandboxId: string,
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(
workspaceId: string,
@ -244,22 +251,13 @@ export interface BackendClient {
): Promise<{ endpoint: string; token?: string }>;
getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchHandoff(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse>;
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 }>;
createWorkbenchSession(workspaceId: string, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>;
renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
): 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>;
@ -269,6 +267,7 @@ export interface BackendClient {
revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise<void>;
health(): Promise<{ ok: true }>;
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
starSandboxAgentRepo(workspaceId: string): Promise<StarSandboxAgentRepoResult>;
}
export function rivetEndpoint(config: AppConfig): string {
@ -278,7 +277,7 @@ export function rivetEndpoint(config: AppConfig): string {
export function createBackendClientFromConfig(config: AppConfig): BackendClient {
return createBackendClient({
endpoint: rivetEndpoint(config),
defaultWorkspaceId: config.workspace.default
defaultWorkspaceId: config.workspace.default,
});
}
@ -317,7 +316,7 @@ async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise<unk
async function fetchMetadataWithRetry(
endpoint: string,
namespace: string | undefined,
opts: { timeoutMs: number; requestTimeoutMs: number }
opts: { timeoutMs: number; requestTimeoutMs: number },
): Promise<RivetMetadataResponse> {
const base = new URL(endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
@ -335,10 +334,7 @@ async function fetchMetadataWithRetry(
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,
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,
@ -353,11 +349,7 @@ async function fetchMetadataWithRetry(
}
}
export async function readBackendMetadata(input: {
endpoint: string;
namespace?: string;
timeoutMs?: number;
}): Promise<BackendMetadata> {
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) {
@ -371,21 +363,14 @@ export async function readBackendMetadata(input: {
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,
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> {
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);
@ -394,11 +379,7 @@ export async function checkBackendHealth(input: {
}
}
async function probeMetadataEndpoint(
endpoint: string,
namespace: string | undefined,
timeoutMs: number
): Promise<boolean> {
async function probeMetadataEndpoint(endpoint: string, namespace: string | undefined, timeoutMs: number): Promise<boolean> {
try {
const base = new URL(endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
@ -448,19 +429,15 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const initialNamespace = undefined;
const metadata = await fetchMetadataWithRetry(options.endpoint, initialNamespace, {
timeoutMs: 30_000,
requestTimeoutMs: 8_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;
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 shouldUseCandidate = metadata.clientEndpoint ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) : true;
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
return createClient({
@ -477,14 +454,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
(await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId
createWithInput: workspaceId,
});
const sandboxByKey = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle> => {
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));
};
@ -494,11 +467,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return message.includes("Actor not found");
}
const sandboxByActorIdFromHandoff = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle | null> => {
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);
@ -509,12 +478,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
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);
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);
@ -535,7 +505,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
run: (handle: SandboxInstanceHandle) => Promise<T>
run: (handle: SandboxInstanceHandle) => Promise<T>,
): Promise<T> => {
const handle = await sandboxByKey(workspaceId, providerId, sandboxId);
try {
@ -679,6 +649,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await workspace(input.workspaceId)).createHandoff(input);
},
async starSandboxAgentRepo(workspaceId: string): Promise<StarSandboxAgentRepoResult> {
return (await workspace(workspaceId)).starSandboxAgentRepo({ workspaceId });
},
async listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]> {
return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId });
},
@ -694,7 +668,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
async getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord> {
return (await workspace(workspaceId)).getHandoff({
workspaceId,
handoffId
handoffId,
});
},
@ -710,7 +684,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await workspace(workspaceId)).attachHandoff({
workspaceId,
handoffId,
reason: "cli.attach"
reason: "cli.attach",
});
},
@ -719,7 +693,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
await (await workspace(workspaceId)).pushHandoff({
workspaceId,
handoffId,
reason: "cli.push"
reason: "cli.push",
});
return;
}
@ -727,7 +701,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
await (await workspace(workspaceId)).syncHandoff({
workspaceId,
handoffId,
reason: "cli.sync"
reason: "cli.sync",
});
return;
}
@ -735,7 +709,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
await (await workspace(workspaceId)).mergeHandoff({
workspaceId,
handoffId,
reason: "cli.merge"
reason: "cli.merge",
});
return;
}
@ -743,14 +717,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
await (await workspace(workspaceId)).archiveHandoff({
workspaceId,
handoffId,
reason: "cli.archive"
reason: "cli.archive",
});
return;
}
await (await workspace(workspaceId)).killHandoff({
workspaceId,
handoffId,
reason: "cli.kill"
reason: "cli.kill",
});
},
@ -762,23 +736,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
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
})
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
status: created.status,
};
},
@ -786,28 +756,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input?: { cursor?: string; limit?: number }
input?: { cursor?: string; limit?: number },
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listSessions(input ?? {})
);
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 }
input: { sessionId: string; cursor?: string; limit?: number },
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listSessionEvents(input)
);
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.listSessionEvents(input));
},
async createSandboxProcess(input: {
@ -913,16 +873,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
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
})
await withSandboxHandle(input.workspaceId, input.providerId, input.sandboxId, async (handle) =>
handle.sendPrompt({
sessionId: input.sessionId,
prompt: input.prompt,
notification: input.notification,
}),
);
},
@ -930,27 +886,17 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
sessionId: string
sessionId: string,
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.sessionStatus({ sessionId })
);
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.sessionStatus({ sessionId }));
},
async sandboxProviderState(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
sandboxId: string,
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.providerState()
);
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.providerState());
},
async getSandboxAgentConnection(
@ -974,10 +920,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return subscribeWorkbench(workspaceId, listener);
},
async createWorkbenchHandoff(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse> {
async createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
return (await workspace(workspaceId)).createWorkbenchHandoff(input);
},
@ -993,45 +936,27 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
await (await workspace(workspaceId)).renameWorkbenchBranch(input);
},
async createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }> {
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> {
async renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
): Promise<void> {
async setWorkbenchSessionUnread(workspaceId: string, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
await (await workspace(workspaceId)).setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(
workspaceId: string,
input: HandoffWorkbenchUpdateDraftInput
): Promise<void> {
async updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
await (await workspace(workspaceId)).updateWorkbenchDraft(input);
},
async changeWorkbenchModel(
workspaceId: string,
input: HandoffWorkbenchChangeModelInput
): Promise<void> {
async changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void> {
await (await workspace(workspaceId)).changeWorkbenchModel(input);
},
async sendWorkbenchMessage(
workspaceId: string,
input: HandoffWorkbenchSendMessageInput
): Promise<void> {
async sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void> {
await (await workspace(workspaceId)).sendWorkbenchMessage(input);
},
@ -1058,13 +983,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
await (await workspace(workspaceId)).useWorkspace({
workspaceId
workspaceId,
});
return { ok: true };
},
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
return (await workspace(workspaceId)).useWorkspace({ workspaceId });
}
},
};
}

View file

@ -12,11 +12,7 @@ export function handoffKey(workspaceId: string, repoId: string, handoffId: strin
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
}
export function sandboxInstanceKey(
workspaceId: string,
providerId: string,
sandboxId: string
): ActorKey {
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
}
@ -32,13 +28,7 @@ export function projectBranchSyncKey(workspaceId: string, repoId: string): Actor
return ["ws", workspaceId, "project", repoId, "branch-sync"];
}
export function handoffStatusSyncKey(
workspaceId: string,
repoId: string,
handoffId: string,
sandboxId: string,
sessionId: string
): ActorKey {
export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: string, sessionId: string): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
}

View file

@ -22,6 +22,7 @@ import type {
RepoRecord,
RepoStackActionInput,
RepoStackActionResult,
StarSandboxAgentRepoResult,
SwitchResult,
} from "@openhandoff/shared";
import type {
@ -493,5 +494,12 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
return { workspaceId };
},
async starSandboxAgentRepo(): Promise<StarSandboxAgentRepoResult> {
return {
repo: "rivet-dev/sandbox-agent",
starredAt: nowMs(),
};
},
};
}

View file

@ -84,7 +84,9 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
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"),
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,
@ -311,9 +313,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
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,
),
tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)),
}));
}
@ -324,9 +324,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate,
),
tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)),
}));
}

View file

@ -192,8 +192,6 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
}
}
export function createRemoteWorkbenchClient(
options: RemoteWorkbenchClientOptions,
): HandoffWorkbenchClient {
export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): HandoffWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -1,13 +1,6 @@
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",
"running",
"idle",
"archived",
"killed",
"error"
] as const;
export const HANDOFF_STATUS_GROUPS = ["queued", "running", "idle", "archived", "killed", "error"] as const;
export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
@ -27,7 +20,7 @@ const QUEUED_STATUSES = new Set<HandoffStatus>([
"archive_release_sandbox",
"archive_finalize",
"kill_destroy_sandbox",
"kill_finalize"
"kill_finalize",
]);
export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup {
@ -47,7 +40,7 @@ function emptyStatusCounts(): Record<HandoffStatusGroup, number> {
idle: 0,
archived: 0,
killed: 0,
error: 0
error: 0,
};
}
@ -78,14 +71,7 @@ export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRec
}
return rows.filter((row) => {
const fields = [
row.branchName ?? "",
row.title ?? "",
row.handoffId,
row.task,
row.prAuthor ?? "",
row.reviewer ?? ""
];
const fields = [row.branchName ?? "", row.title ?? "", row.handoffId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
return fields.some((field) => fuzzyMatch(field, q));
});
}
@ -113,6 +99,6 @@ export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary {
return {
total: rows.length,
byStatus,
byProvider
byProvider,
};
}

View file

@ -45,9 +45,7 @@ export interface HandoffWorkbenchClient {
changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
}
export function createHandoffWorkbenchClient(
options: CreateHandoffWorkbenchClientOptions,
): HandoffWorkbenchClient {
export function createHandoffWorkbenchClient(options: CreateHandoffWorkbenchClientOptions): HandoffWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
}

File diff suppressed because it is too large Load diff

View file

@ -33,13 +33,7 @@ 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> {
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 (;;) {
@ -75,11 +69,7 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi
});
}
async function ensureRemoteBranchExists(
token: string,
fullName: string,
branchName: string
): Promise<void> {
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()}`);
@ -90,11 +80,7 @@ async function ensureRemoteBranchExists(
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" }
);
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()}`);
}
@ -120,78 +106,69 @@ async function ensureRemoteBranchExists(
}
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)}`;
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,
});
const client = createBackendClient({
endpoint,
defaultWorkspaceId: workspaceId,
});
try {
await ensureRemoteBranchExists(githubToken, fullName, seededBranch);
try {
await ensureRemoteBranchExists(githubToken, fullName, seededBranch);
const repo = await client.addRepo(workspaceId, repoRemote);
expect(repo.remoteUrl).toBe(normalizedRepoRemote);
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)
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",
);
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(() => {});
}
const stackResult = await client.runRepoStackAction({
workspaceId,
repoId: repo.repoId,
action: "restack_repo",
});
expect(stackResult.executed).toBe(true);
expect(stackResult.action).toBe("restack_repo");
await poll<HistoryEvent[]>(
"repo stack action history event",
60_000,
1_000,
async () => client.listHistory({ workspaceId, limit: 200 }),
(events) =>
events.some((event) => {
if (event.kind !== "repo.stack_action") {
return false;
}
const payload = parseHistoryPayload(event);
return payload.action === "restack_repo";
}),
);
const postActionOverview = await client.getRepoOverview(workspaceId, repo.repoId);
const seededRow = postActionOverview.branches.find((row) => row.branchName === seededBranch);
expect(Boolean(seededRow)).toBe(true);
expect(postActionOverview.fetchedAt).toBeGreaterThan(overview.fetchedAt);
} finally {
await githubApi(githubToken, `repos/${fullName}/git/refs/heads/${encodeURIComponent(seededBranch)}`, { method: "DELETE" }).catch(() => {});
}
);
});
});

View file

@ -54,7 +54,7 @@ async function poll<T>(
intervalMs: number,
fn: () => Promise<T>,
isDone: (value: T) => boolean,
onTick?: (value: T) => void
onTick?: (value: T) => void,
): Promise<T> {
const start = Date.now();
let last: T;
@ -117,7 +117,7 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, workspa
prSubmitted: handoff.prSubmitted,
},
null,
2
2,
),
"=== history (most recent first) ===",
historySummary || "(none)",
@ -143,209 +143,190 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi
}
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");
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 { 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 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}`);
});
const repo = await client.addRepo(workspaceId, repoRemote);
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
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}`,
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}`);
});
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;
sessionId = withSession.activeSessionId!;
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");
}
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) => {
},
).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")!);
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const payload = parseHistoryPayload(prCreatedEvent);
prNumber = Number(payload.prNumber);
const prUrl = String(payload.prUrl ?? "");
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}`);
});
expect(prNumber).toBeGreaterThan(0);
expect(prUrl).toContain("/pull/");
sessionId = withSession.activeSessionId!;
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);
await poll<{ id: string }[]>(
"session transcript bootstrap events",
// 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 () =>
(
await client.listSandboxSessionEvents(workspaceId, withSession.providerId, sandboxId!, {
sessionId: sessionId!,
limit: 40,
})
).items,
(events) => events.length > 0
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);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
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(() => {});
}
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(() => {});
}
if (branchName) {
await githubApi(githubToken, `repos/${fullName}/git/refs/heads/${encodeURIComponent(branchName)}`, { method: "DELETE" }).catch(() => {});
}
}
);
});
});

View file

@ -1,13 +1,7 @@
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 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";
@ -48,13 +42,7 @@ async function seedSandboxFile(workspaceId: string, handoffId: string, filePath:
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> {
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;
@ -147,10 +135,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string {
return JSON.stringify(payload);
}
function transcriptIncludesAgentText(
transcript: WorkbenchTranscriptEvent[],
expectedText: string,
): boolean {
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
return transcript
.filter((event) => event.sender === "agent")
.map((event) => extractEventText(event))
@ -159,176 +144,164 @@ function transcriptIncludesAgentText(
}
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}`;
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 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 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 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 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)
);
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,
},
);
],
});
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
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 seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [],
});
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);
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);
},
);
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 secondTranscript = findTab(withSecondReply, secondTab.tabId).transcript;
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
const secondTab = await client.createWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
model,
});
await client.setWorkbenchSessionUnread(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
unread: false,
});
await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId });
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
title: "Follow-up Session",
});
const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
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,
},
],
});
await client.closeWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
});
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);
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.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [],
});
await client.revertWorkbenchFile(workspaceId, {
handoffId: created.handoffId,
path: expectedFile,
});
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 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),
);
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");
},
);
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);
expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`);
expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session");
});
});

View file

@ -1,11 +1,5 @@
import { describe, expect, it } from "vitest";
import type {
HandoffWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/shared";
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";
@ -44,13 +38,7 @@ 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> {
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;
@ -174,8 +162,7 @@ async function measureWorkbenchSnapshot(
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),
(sum, handoff) => sum + handoff.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
0,
);
@ -190,142 +177,133 @@ async function measureWorkbenchSnapshot(
}
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);
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 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 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;
}> = [];
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);
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
expect(provisioned.tabs.length).toBeGreaterThan(0);
const primaryTab = provisioned.tabs[0]!;
expect(transcriptIncludesAgentText(primaryTab.transcript, initialReply)).toBe(true);
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}`,
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,
task: `Reply with exactly: ${initialReply}`,
});
createHandoffLatencies.push(performance.now() - createStartedAt);
createSessionLatencies.push(performance.now() - createSessionStartedAt);
const provisionStartedAt = performance.now();
const provisioned = await poll(
`handoff ${runId} provisioning`,
12 * 60_000,
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 = handoff.tabs[0];
return Boolean(
tab &&
handoff.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, initialReply),
);
const tab = findTab(handoff, createdSession.tabId);
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
},
);
provisionLatencies.push(performance.now() - provisionStartedAt);
messageRoundTripLatencies.push(performance.now() - messageStartedAt);
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,
}),
);
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.tabId).transcript, expectedReply)).toBe(true);
}
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,
};
const snapshotMetrics = await measureWorkbenchSnapshot(client, workspaceId, 3);
snapshotSeries.push(snapshotMetrics);
console.info(
"[workbench-load-snapshot]",
JSON.stringify({
handoffIndex: handoffIndex + 1,
...snapshotMetrics,
}),
);
}
console.info("[workbench-load-summary]", JSON.stringify(summary));
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,
};
expect(createHandoffLatencies.length).toBe(handoffCount);
expect(provisionLatencies.length).toBe(handoffCount);
expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount);
},
);
console.info("[workbench-load-summary]", JSON.stringify(summary));
expect(createHandoffLatencies.length).toBe(handoffCount);
expect(provisionLatencies.length).toBe(handoffCount);
expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount);
});
});

View file

@ -7,7 +7,7 @@ import {
projectKey,
projectPrSyncKey,
sandboxInstanceKey,
workspaceKey
workspaceKey,
} from "../src/keys.js";
describe("actor keys", () => {
@ -20,7 +20,7 @@ describe("actor keys", () => {
historyKey("default", "repo"),
projectPrSyncKey("default", "repo"),
projectBranchSyncKey("default", "repo"),
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1"),
];
for (const key of keys) {

View file

@ -1,11 +1,6 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@openhandoff/shared";
import {
filterHandoffs,
formatRelativeAge,
fuzzyMatch,
summarizeHandoffs
} from "../src/view-model.js";
import { filterHandoffs, formatRelativeAge, fuzzyMatch, summarizeHandoffs } from "../src/view-model.js";
const sample: HandoffRecord = {
workspaceId: "default",
@ -28,8 +23,8 @@ const sample: HandoffRecord = {
switchTarget: "daytona://sandbox-1",
cwd: null,
createdAt: 1,
updatedAt: 1
}
updatedAt: 1,
},
],
agentType: null,
prSubmitted: false,
@ -43,7 +38,7 @@ const sample: HandoffRecord = {
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1
updatedAt: 1,
};
describe("search helpers", () => {
@ -60,8 +55,8 @@ describe("search helpers", () => {
handoffId: "handoff-2",
branchName: "docs/update-intro",
title: "Docs Intro Refresh",
status: "idle"
}
status: "idle",
},
];
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
@ -79,7 +74,7 @@ describe("summary helpers", () => {
const rows: HandoffRecord[] = [
sample,
{ ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" },
{ ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" }
{ ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" },
];
const summary = summarizeHandoffs(rows);