factory: rename project and handoff actors

This commit is contained in:
Nathan Flurry 2026-03-10 21:55:30 -07:00
parent 3022bce2ad
commit ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions

View file

@ -16,8 +16,9 @@ export interface FactoryAppClient {
signOut(): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<void>;
triggerRepoImport(organizationId: string): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
@ -62,4 +63,3 @@ export function eligibleFactoryOrganizations(snapshot: FactoryAppSnapshot): Fact
const eligible = new Set(user.eligibleOrganizationIds);
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
}

View file

@ -5,21 +5,21 @@ import type {
AppConfig,
FactoryAppSnapshot,
FactoryBillingPlanId,
CreateHandoffInput,
HandoffRecord,
HandoffSummary,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
CreateTaskInput,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ProviderId,
@ -32,7 +32,7 @@ import type {
} from "@sandbox-agent/factory-shared";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill";
type RivetMetadataResponse = {
runtime?: string;
@ -65,35 +65,35 @@ export interface SandboxSessionEventRecord {
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[]>;
createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(input: { workspaceId: string; repoId?: string }): Promise<TaskSummary[]>;
getRepoOverview(input: { workspaceId: string; repoId: string }): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
history(input: HistoryQueryInput): Promise<HistoryEvent[]>;
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>;
switchTask(taskId: string): Promise<SwitchResult>;
getTask(input: { workspaceId: string; taskId: string }): Promise<TaskRecord>;
attachTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
syncTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
mergeTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
archiveTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>;
getWorkbench(input: { workspaceId: string }): Promise<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>;
getWorkbench(input: { workspaceId: string }): Promise<TaskWorkbenchSnapshot>;
createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(input: TaskWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>;
renameWorkbenchSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
updateWorkbenchDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
}
interface SandboxInstanceHandle {
@ -129,27 +129,29 @@ export interface BackendMetadata {
export interface BackendClient {
getAppSnapshot(): Promise<FactoryAppSnapshot>;
signInWithGithub(userId?: string): Promise<FactoryAppSnapshot>;
signInWithGithub(): Promise<void>;
signOutApp(): Promise<FactoryAppSnapshot>;
selectAppOrganization(organizationId: string): Promise<FactoryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<FactoryAppSnapshot>;
triggerAppRepoImport(organizationId: string): Promise<FactoryAppSnapshot>;
reconnectAppGithub(organizationId: string): Promise<FactoryAppSnapshot>;
completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<FactoryAppSnapshot>;
reconnectAppGithub(organizationId: string): Promise<void>;
completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void>;
openAppBillingPortal(organizationId: string): Promise<void>;
cancelAppScheduledRenewal(organizationId: string): Promise<FactoryAppSnapshot>;
resumeAppSubscription(organizationId: string): Promise<FactoryAppSnapshot>;
recordAppSeatUsage(workspaceId: string): Promise<FactoryAppSnapshot>;
addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord>;
listRepos(workspaceId: string): Promise<RepoRecord[]>;
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]>;
createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(workspaceId: string, repoId?: string): Promise<TaskSummary[]>;
getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord>;
getTask(workspaceId: string, taskId: string): Promise<TaskRecord>;
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>;
switchTask(workspaceId: string, taskId: string): Promise<SwitchResult>;
attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void>;
runTaskAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void>;
createSandboxSession(input: {
workspaceId: string;
providerId: ProviderId;
@ -189,31 +191,31 @@ export interface BackendClient {
providerId: ProviderId,
sandboxId: string
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot>;
getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchHandoff(
createWorkbenchTask(
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>;
input: TaskWorkbenchCreateTaskInput
): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
input: TaskWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }>;
renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void>;
renameWorkbenchSession(workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
input: TaskWorkbenchSetSessionUnreadInput
): 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>;
updateWorkbenchDraft(workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void>;
health(): Promise<{ ok: true }>;
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
}
@ -384,6 +386,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
};
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
const sessionFromUrl = url.searchParams.get("factorySession");
if (sessionFromUrl) {
persistAppSessionId(sessionFromUrl);
url.searchParams.delete("factorySession");
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
}
}
const appRequest = async <T>(path: string, init?: RequestInit): Promise<T> => {
const headers = new Headers(init?.headers);
if (appSessionId) {
@ -396,6 +408,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const res = await fetch(`${options.endpoint.replace(/\/$/, "")}${path}`, {
...init,
headers,
credentials: "include",
});
const nextSessionId = res.headers.get("x-factory-session");
if (nextSessionId) {
@ -407,6 +420,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await res.json()) as T;
};
const redirectTo = async (path: string, init?: RequestInit): Promise<void> => {
const response = await appRequest<{ url: string }>(path, init);
if (typeof window !== "undefined") {
window.location.assign(response.url);
}
};
const getClient = async (): Promise<RivetClient> => {
if (clientPromise) {
return clientPromise;
@ -473,18 +493,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return message.includes("Actor not found");
}
const sandboxByActorIdFromHandoff = async (
const sandboxByActorIdFromTask = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle | null> => {
const ws = await workspace(workspaceId);
const rows = await ws.listHandoffs({ workspaceId });
const rows = await ws.listTasks({ workspaceId });
const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
for (const row of candidates) {
try {
const detail = await ws.getHandoff({ workspaceId, handoffId: row.handoffId });
const detail = await ws.getTask({ workspaceId, taskId: row.taskId });
if (detail.providerId !== providerId) {
continue;
}
@ -500,10 +520,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isActorNotFoundError(error) && !message.includes("Unknown handoff")) {
if (!isActorNotFoundError(error) && !message.includes("Unknown task")) {
throw error;
}
// Best effort fallback path; ignore missing handoff actors here.
// Best effort fallback path; ignore missing task actors here.
}
}
@ -523,7 +543,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (!isActorNotFoundError(error)) {
throw error;
}
const fallback = await sandboxByActorIdFromHandoff(workspaceId, providerId, sandboxId);
const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId);
if (!fallback) {
throw error;
}
@ -605,11 +625,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await appRequest<FactoryAppSnapshot>("/app/snapshot");
},
async signInWithGithub(userId?: string): Promise<FactoryAppSnapshot> {
return await appRequest<FactoryAppSnapshot>("/app/sign-in", {
method: "POST",
body: JSON.stringify(userId ? { userId } : {}),
});
async signInWithGithub(): Promise<void> {
if (typeof window !== "undefined") {
window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`);
return;
}
await redirectTo("/app/auth/github/start");
},
async signOutApp(): Promise<FactoryAppSnapshot> {
@ -641,22 +662,25 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
});
},
async reconnectAppGithub(organizationId: string): Promise<FactoryAppSnapshot> {
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/reconnect`, {
async reconnectAppGithub(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/reconnect`, {
method: "POST",
});
},
async completeAppHostedCheckout(
organizationId: string,
planId: FactoryBillingPlanId,
): Promise<FactoryAppSnapshot> {
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/checkout`, {
async completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/billing/checkout`, {
method: "POST",
body: JSON.stringify({ planId }),
});
},
async openAppBillingPortal(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/billing/portal`, {
method: "POST",
});
},
async cancelAppScheduledRenewal(organizationId: string): Promise<FactoryAppSnapshot> {
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/cancel`, {
method: "POST",
@ -683,12 +707,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await workspace(workspaceId)).listRepos({ workspaceId });
},
async createHandoff(input: CreateHandoffInput): Promise<HandoffRecord> {
return (await workspace(input.workspaceId)).createHandoff(input);
async createTask(input: CreateTaskInput): Promise<TaskRecord> {
return (await workspace(input.workspaceId)).createTask(input);
},
async listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]> {
return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId });
async listTasks(workspaceId: string, repoId?: string): Promise<TaskSummary[]> {
return (await workspace(workspaceId)).listTasks({ workspaceId, repoId });
},
async getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview> {
@ -699,10 +723,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await workspace(input.workspaceId)).runRepoStackAction(input);
},
async getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord> {
return (await workspace(workspaceId)).getHandoff({
async getTask(workspaceId: string, taskId: string): Promise<TaskRecord> {
return (await workspace(workspaceId)).getTask({
workspaceId,
handoffId
taskId
});
},
@ -710,58 +734,62 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await workspace(input.workspaceId)).history(input);
},
async switchHandoff(workspaceId: string, handoffId: string): Promise<SwitchResult> {
return (await workspace(workspaceId)).switchHandoff(handoffId);
async switchTask(workspaceId: string, taskId: string): Promise<SwitchResult> {
return (await workspace(workspaceId)).switchTask(taskId);
},
async attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> {
return (await workspace(workspaceId)).attachHandoff({
async attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
return (await workspace(workspaceId)).attachTask({
workspaceId,
handoffId,
taskId,
reason: "cli.attach"
});
},
async runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise<void> {
async runAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void> {
if (action === "push") {
await (await workspace(workspaceId)).pushHandoff({
await (await workspace(workspaceId)).pushTask({
workspaceId,
handoffId,
taskId,
reason: "cli.push"
});
return;
}
if (action === "sync") {
await (await workspace(workspaceId)).syncHandoff({
await (await workspace(workspaceId)).syncTask({
workspaceId,
handoffId,
taskId,
reason: "cli.sync"
});
return;
}
if (action === "merge") {
await (await workspace(workspaceId)).mergeHandoff({
await (await workspace(workspaceId)).mergeTask({
workspaceId,
handoffId,
taskId,
reason: "cli.merge"
});
return;
}
if (action === "archive") {
await (await workspace(workspaceId)).archiveHandoff({
await (await workspace(workspaceId)).archiveTask({
workspaceId,
handoffId,
taskId,
reason: "cli.archive"
});
return;
}
await (await workspace(workspaceId)).killHandoff({
await (await workspace(workspaceId)).killTask({
workspaceId,
handoffId,
taskId,
reason: "cli.kill"
});
},
async runTaskAction(workspaceId: string, taskId: string, action: TaskAction): Promise<void> {
await this.runAction(workspaceId, taskId, action);
},
async createSandboxSession(input: {
workspaceId: string;
providerId: ProviderId;
@ -866,7 +894,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
);
},
async getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot> {
async getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot> {
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
},
@ -874,80 +902,80 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return subscribeWorkbench(workspaceId, listener);
},
async createWorkbenchHandoff(
async createWorkbenchTask(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse> {
return (await workspace(workspaceId)).createWorkbenchHandoff(input);
input: TaskWorkbenchCreateTaskInput
): Promise<TaskWorkbenchCreateTaskResponse> {
return (await workspace(workspaceId)).createWorkbenchTask(input);
},
async markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
async markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).markWorkbenchUnread(input);
},
async renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchHandoff(input);
async renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchTask(input);
},
async renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
async renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchBranch(input);
},
async createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
input: TaskWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }> {
return await (await workspace(workspaceId)).createWorkbenchSession(input);
},
async renameWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchRenameSessionInput
input: TaskWorkbenchRenameSessionInput
): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
input: TaskWorkbenchSetSessionUnreadInput
): Promise<void> {
await (await workspace(workspaceId)).setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(
workspaceId: string,
input: HandoffWorkbenchUpdateDraftInput
input: TaskWorkbenchUpdateDraftInput
): Promise<void> {
await (await workspace(workspaceId)).updateWorkbenchDraft(input);
},
async changeWorkbenchModel(
workspaceId: string,
input: HandoffWorkbenchChangeModelInput
input: TaskWorkbenchChangeModelInput
): Promise<void> {
await (await workspace(workspaceId)).changeWorkbenchModel(input);
},
async sendWorkbenchMessage(
workspaceId: string,
input: HandoffWorkbenchSendMessageInput
input: TaskWorkbenchSendMessageInput
): Promise<void> {
await (await workspace(workspaceId)).sendWorkbenchMessage(input);
},
async stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
async stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).stopWorkbenchSession(input);
},
async closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
async closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).closeWorkbenchSession(input);
},
async publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
async publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).publishWorkbenchPr(input);
},
async revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise<void> {
async revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void> {
await (await workspace(workspaceId)).revertWorkbenchFile(input);
},

View file

@ -4,41 +4,41 @@ export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
}
export function projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
export function repoKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId];
}
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
export function taskKey(workspaceId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "task", taskId];
}
export function sandboxInstanceKey(
workspaceId: string,
providerId: string,
sandboxId: 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"];
return ["ws", workspaceId, "repo", repoId, "history"];
}
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "pr-sync"];
export function repoPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "pr-sync"];
}
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "branch-sync"];
export function repoBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "branch-sync"];
}
export function handoffStatusSyncKey(
export function taskStatusSyncKey(
workspaceId: string,
repoId: string,
handoffId: string,
taskId: string,
sandboxId: string,
sessionId: 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];
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
return ["ws", workspaceId, "task", taskId, "status-sync", repoId, sandboxId, sessionId];
}

View file

@ -1,9 +1,9 @@
import { injectMockLatency } from "./mock/latency.js";
export type MockBillingPlanId = "free" | "team" | "enterprise";
export type MockBillingPlanId = "free" | "team";
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
export type MockRepoImportStatus = "ready" | "not_started" | "importing";
export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
export type MockOrganizationKind = "personal" | "organization";
export interface MockFactoryUser {
@ -45,8 +45,10 @@ export interface MockFactoryBillingState {
export interface MockFactoryGithubState {
connectedAccount: string;
installationStatus: MockGithubInstallationStatus;
syncStatus: MockGithubSyncStatus;
importedRepoCount: number;
lastSyncLabel: string;
lastSyncAt: number | null;
}
export interface MockFactoryOrganizationSettings {
@ -67,7 +69,6 @@ export interface MockFactoryOrganization {
billing: MockFactoryBillingState;
members: MockFactoryOrganizationMember[];
seatAssignments: string[];
repoImportStatus: MockRepoImportStatus;
repoCatalog: string[];
}
@ -95,8 +96,9 @@ export interface MockFactoryAppClient {
signOut(): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
triggerRepoImport(organizationId: string): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
@ -111,6 +113,21 @@ function isoDate(daysFromNow: number): string {
return value.toISOString();
}
function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus {
switch (value) {
case "ready":
case "synced":
return "synced";
case "importing":
case "syncing":
return "syncing";
case "error":
return "error";
default:
return "pending";
}
}
function buildDefaultSnapshot(): MockFactoryAppSnapshot {
return {
auth: {
@ -160,8 +177,10 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
github: {
connectedAccount: "nathan",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
lastSyncAt: Date.now() - 60_000,
},
billing: {
planId: "free",
@ -177,7 +196,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
],
seatAssignments: ["nathan@acme.dev"],
repoImportStatus: "ready",
repoCatalog: ["nathan/personal-site"],
},
{
@ -195,8 +213,10 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
github: {
connectedAccount: "acme",
installationStatus: "connected",
syncStatus: "pending",
importedRepoCount: 3,
lastSyncLabel: "Synced 4 minutes ago",
lastSyncLabel: "Waiting for first import",
lastSyncAt: null,
},
billing: {
planId: "team",
@ -218,7 +238,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
{ id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" },
],
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
repoImportStatus: "not_started",
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
},
{
@ -236,18 +255,20 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
github: {
connectedAccount: "rivet-dev",
installationStatus: "reconnect_required",
syncStatus: "error",
importedRepoCount: 4,
lastSyncLabel: "Sync stalled 2 hours ago",
lastSyncAt: Date.now() - 2 * 60 * 60_000,
},
billing: {
planId: "enterprise",
planId: "team",
status: "trialing",
seatsIncluded: 25,
seatsIncluded: 5,
trialEndsAt: isoDate(12),
renewalAt: isoDate(12),
stripeCustomerId: "cus_mock_rivet_enterprise",
paymentMethodLabel: "ACH verified",
invoices: [{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
stripeCustomerId: "cus_mock_rivet_team",
paymentMethodLabel: "Visa ending in 4242",
invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
},
members: [
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
@ -255,7 +276,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
{ id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" },
],
seatAssignments: ["jamie@rivet.dev"],
repoImportStatus: "not_started",
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
},
{
@ -273,8 +293,10 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
github: {
connectedAccount: "jamie",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced yesterday",
lastSyncAt: Date.now() - 24 * 60 * 60_000,
},
billing: {
planId: "free",
@ -288,7 +310,6 @@ function buildDefaultSnapshot(): MockFactoryAppSnapshot {
},
members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }],
seatAssignments: ["jamie@rivet.dev"],
repoImportStatus: "ready",
repoCatalog: ["jamie/demo-app"],
},
],
@ -306,11 +327,23 @@ function parseStoredSnapshot(): MockFactoryAppSnapshot | null {
}
try {
const parsed = JSON.parse(raw) as MockFactoryAppSnapshot;
const parsed = JSON.parse(raw) as MockFactoryAppSnapshot & {
organizations?: Array<MockFactoryOrganization & { repoImportStatus?: string }>;
};
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
return {
...parsed,
organizations: (parsed.organizations ?? []).map((organization: MockFactoryOrganization & { repoImportStatus?: string }) => ({
...organization,
github: {
...organization.github,
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
lastSyncAt: organization.github?.lastSyncAt ?? null,
},
})),
};
} catch {
return null;
}
@ -330,8 +363,6 @@ function planSeatsIncluded(planId: MockBillingPlanId): number {
return 1;
case "team":
return 5;
case "enterprise":
return 25;
}
}
@ -396,8 +427,8 @@ class MockFactoryAppStore implements MockFactoryAppClient {
activeOrganizationId: organizationId,
}));
if (org.repoImportStatus !== "ready") {
await this.triggerRepoImport(organizationId);
if (org.github.syncStatus !== "synced") {
await this.triggerGithubSync(organizationId);
}
}
@ -415,7 +446,7 @@ class MockFactoryAppStore implements MockFactoryAppClient {
}));
}
async triggerRepoImport(organizationId: string): Promise<void> {
async triggerGithubSync(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
const existingTimer = this.importTimers.get(organizationId);
@ -425,22 +456,23 @@ class MockFactoryAppStore implements MockFactoryAppClient {
this.updateOrganization(organizationId, (organization) => ({
...organization,
repoImportStatus: "importing",
github: {
...organization.github,
lastSyncLabel: "Importing repository catalog...",
syncStatus: "syncing",
lastSyncLabel: "Syncing repositories...",
},
}));
const timer = setTimeout(() => {
this.updateOrganization(organizationId, (organization) => ({
...organization,
repoImportStatus: "ready",
github: {
...organization.github,
importedRepoCount: organization.repoCatalog.length,
installationStatus: "connected",
syncStatus: "synced",
lastSyncLabel: "Synced just now",
lastSyncAt: Date.now(),
},
}));
this.importTimers.delete(organizationId);
@ -461,13 +493,13 @@ class MockFactoryAppStore implements MockFactoryAppClient {
seatsIncluded: planSeatsIncluded(planId),
trialEndsAt: null,
renewalAt: isoDate(30),
paymentMethodLabel: planId === "enterprise" ? "ACH verified" : "Visa ending in 4242",
paymentMethodLabel: "Visa ending in 4242",
invoices: [
{
id: `inv-${organizationId}-${Date.now()}`,
label: `${organization.settings.displayName} ${planId} upgrade`,
issuedAt: new Date().toISOString().slice(0, 10),
amountUsd: planId === "team" ? 240 : planId === "enterprise" ? 1200 : 0,
amountUsd: planId === "team" ? 240 : 0,
status: "paid",
},
...organization.billing.invoices,
@ -476,6 +508,10 @@ class MockFactoryAppStore implements MockFactoryAppClient {
}));
}
async openBillingPortal(_organizationId: string): Promise<void> {
await this.injectAsyncLatency();
}
async cancelScheduledRenewal(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
@ -508,7 +544,9 @@ class MockFactoryAppStore implements MockFactoryAppClient {
github: {
...organization.github,
installationStatus: "connected",
syncStatus: "pending",
lastSyncLabel: "Reconnected just now",
lastSyncAt: Date.now(),
},
}));
}

View file

@ -1,7 +1,7 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchProjects,
groupWorkbenchRepos,
nowMs,
providerAgent,
randomReply,
@ -12,24 +12,24 @@ import {
import { getMockFactoryAppClient } from "../mock-app.js";
import { injectMockLatency } from "./latency.js";
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
WorkbenchAgentTab as AgentTab,
WorkbenchHandoff as Handoff,
TaskWorkbenchSnapshot,
WorkbenchTask as Task,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/factory-shared";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
import type { TaskWorkbenchClient } from "../workbench-client.js";
function buildTranscriptEvent(params: {
sessionId: string;
@ -49,8 +49,8 @@ function buildTranscriptEvent(params: {
};
}
class MockWorkbenchStore implements HandoffWorkbenchClient {
private snapshot: HandoffWorkbenchSnapshot;
class MockWorkbenchStore implements TaskWorkbenchClient {
private snapshot: TaskWorkbenchSnapshot;
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -58,7 +58,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
this.snapshot = buildInitialMockLayoutViewModel(workspaceId);
}
getSnapshot(): HandoffWorkbenchSnapshot {
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
@ -69,18 +69,19 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
};
}
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
await this.injectAsyncLatency();
const id = uid();
const tabId = `session-${id}`;
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
if (!repo) {
throw new Error(`Cannot create mock handoff for unknown repo ${input.repoId}`);
throw new Error(`Cannot create mock task for unknown repo ${input.repoId}`);
}
const nextHandoff: Handoff = {
const nextTask: Task = {
id,
repoId: repo.id,
title: input.title?.trim() || "New Handoff",
repoIds: input.repoIds?.length ? [...new Set([repo.id, ...input.repoIds])] : [repo.id],
title: input.title?.trim() || "New Task",
status: "new",
repoName: repo.label,
updatedAtMs: nowMs(),
@ -108,100 +109,100 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
this.updateState((current) => ({
...current,
handoffs: [nextHandoff, ...current.handoffs],
tasks: [nextTask, ...current.tasks],
}));
const task = input.task.trim();
if (task) {
await this.sendMessage({
handoffId: id,
taskId: id,
tabId,
text: task,
attachments: [],
});
}
return { handoffId: id, tabId };
return { taskId: id, tabId };
}
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
this.updateHandoff(input.handoffId, (handoff) => {
const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null;
this.updateTask(input.taskId, (task) => {
const targetTab = task.tabs[task.tabs.length - 1] ?? null;
if (!targetTab) {
return handoff;
return task;
}
return {
...handoff,
tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
...task,
tabs: task.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
};
});
}
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.injectAsyncLatency();
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`);
throw new Error(`Cannot rename task ${input.taskId} to an empty title`);
}
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() }));
this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() }));
}
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
await this.injectAsyncLatency();
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`);
throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`);
}
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() }));
this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() }));
}
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() }));
this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() }));
}
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1;
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1;
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
}));
}
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
async pushTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.injectAsyncLatency();
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
}));
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.injectAsyncLatency();
this.updateHandoff(input.handoffId, (handoff) => {
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...handoff.diffs };
this.updateTask(input.taskId, (task) => {
const file = task.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...task.diffs };
delete nextDiffs[input.path];
return {
...handoff,
fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path),
...task,
fileChanges: task.fileChanges.filter((entry) => entry.path !== input.path),
diffs: nextDiffs,
fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree,
fileTree: file?.type === "A" ? removeFileTreePath(task.fileTree, input.path) : task.fileTree,
};
});
}
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
this.assertTab(input.handoffId, input.tabId);
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
this.assertTab(input.taskId, input.tabId);
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
tabs: handoff.tabs.map((tab) =>
tabs: task.tabs.map((tab) =>
tab.id === input.tabId
? {
...tab,
@ -216,30 +217,30 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}));
}
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.injectAsyncLatency();
const text = input.text.trim();
if (!text) {
throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`);
throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`);
}
this.assertTab(input.handoffId, input.tabId);
this.assertTab(input.taskId, input.tabId);
const startedAtMs = nowMs();
getMockFactoryAppClient().recordSeatUsage(this.snapshot.workspaceId);
this.updateHandoff(input.handoffId, (currentHandoff) => {
const isFirstOnHandoff = currentHandoff.status === "new";
this.updateTask(input.taskId, (currentTask) => {
const isFirstOnTask = currentTask.status === "new";
const synthesizedTitle = text.length > 50 ? `${text.slice(0, 47)}...` : text;
const newTitle =
isFirstOnHandoff && currentHandoff.title === "New Handoff" ? synthesizedTitle : currentHandoff.title;
isFirstOnTask && currentTask.title === "New Task" ? synthesizedTitle : currentTask.title;
const newBranch =
isFirstOnHandoff && !currentHandoff.branch ? `feat/${slugify(synthesizedTitle)}` : currentHandoff.branch;
isFirstOnTask && !currentTask.branch ? `feat/${slugify(synthesizedTitle)}` : currentTask.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "client",
createdAt: startedAtMs,
eventIndex: candidateEventIndex(currentHandoff, input.tabId),
eventIndex: candidateEventIndex(currentTask, input.tabId),
payload: {
method: "session/prompt",
params: {
@ -249,12 +250,12 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
});
return {
...currentHandoff,
...currentTask,
title: newTitle,
branch: newBranch,
status: "running",
updatedAtMs: startedAtMs,
tabs: currentHandoff.tabs.map((candidate) =>
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId
? {
...candidate,
@ -276,14 +277,14 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}
const timer = setTimeout(() => {
const handoff = this.requireHandoff(input.handoffId);
const replyTab = this.requireTab(handoff, input.tabId);
const task = this.requireTask(input.taskId);
const replyTab = this.requireTab(task, input.tabId);
const completedAtMs = nowMs();
const replyEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "agent",
createdAt: completedAtMs,
eventIndex: candidateEventIndex(handoff, input.tabId),
eventIndex: candidateEventIndex(task, input.tabId),
payload: {
result: {
text: randomReply(),
@ -292,8 +293,8 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
},
});
this.updateHandoff(input.handoffId, (currentHandoff) => {
const updatedTabs = currentHandoff.tabs.map((candidate) => {
this.updateTask(input.taskId, (currentTask) => {
const updatedTabs = currentTask.tabs.map((candidate) => {
if (candidate.id !== input.tabId) {
return candidate;
}
@ -309,10 +310,10 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentHandoff,
...currentTask,
updatedAtMs: completedAtMs,
tabs: updatedTabs,
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
@ -322,75 +323,75 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
this.pendingTimers.set(input.tabId, timer);
}
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
await this.injectAsyncLatency();
this.assertTab(input.handoffId, input.tabId);
this.assertTab(input.taskId, 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) =>
this.updateTask(input.taskId, (currentTask) => {
const updatedTabs = currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
);
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentHandoff,
...currentTask,
updatedAtMs: nowMs(),
tabs: updatedTabs,
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
}
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.injectAsyncLatency();
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate,
),
}));
}
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.injectAsyncLatency();
const title = input.title.trim();
if (!title) {
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
}
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate,
),
}));
}
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
await this.injectAsyncLatency();
this.updateHandoff(input.handoffId, (currentHandoff) => {
if (currentHandoff.tabs.length <= 1) {
return currentHandoff;
this.updateTask(input.taskId, (currentTask) => {
if (currentTask.tabs.length <= 1) {
return currentTask;
}
return {
...currentHandoff,
tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId),
...currentTask,
tabs: currentTask.tabs.filter((candidate) => candidate.id !== input.tabId),
};
});
}
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
await this.injectAsyncLatency();
this.assertHandoff(input.handoffId);
this.assertTask(input.taskId);
const nextTab: AgentTab = {
id: uid(),
sessionId: null,
sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`,
sessionName: `Session ${this.requireTask(input.taskId).tabs.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
@ -401,43 +402,44 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
transcript: [],
};
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
tabs: [...currentHandoff.tabs, nextTab],
tabs: [...currentTask.tabs, nextTab],
}));
return { tabId: nextTab.id };
}
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
await this.injectAsyncLatency();
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
if (!group) {
throw new Error(`Unable to resolve model provider for ${input.model}`);
}
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
),
}));
}
private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void {
private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void {
const nextSnapshot = updater(this.snapshot);
this.snapshot = {
...nextSnapshot,
projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
repoSections: groupWorkbenchRepos(nextSnapshot.repos, nextSnapshot.tasks),
tasks: nextSnapshot.tasks,
};
this.notify();
}
private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void {
this.assertHandoff(handoffId);
private updateTask(taskId: string, updater: (task: Task) => Task): void {
this.assertTask(taskId);
this.updateState((current) => ({
...current,
handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)),
tasks: current.tasks.map((task) => (task.id === taskId ? updater(task) : task)),
}));
}
@ -447,27 +449,27 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}
}
private assertHandoff(handoffId: string): void {
this.requireHandoff(handoffId);
private assertTask(taskId: string): void {
this.requireTask(taskId);
}
private assertTab(handoffId: string, tabId: string): void {
const handoff = this.requireHandoff(handoffId);
this.requireTab(handoff, tabId);
private assertTab(taskId: string, tabId: string): void {
const task = this.requireTask(taskId);
this.requireTab(task, 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}`);
private requireTask(taskId: string): Task {
const task = this.snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`Unable to find mock task ${taskId}`);
}
return handoff;
return task;
}
private requireTab(handoff: Handoff, tabId: string): AgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
private requireTab(task: Task, tabId: string): AgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`);
throw new Error(`Unable to find mock tab ${tabId} in task ${task.id}`);
}
return tab;
}
@ -477,14 +479,14 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}
}
function candidateEventIndex(handoff: Handoff, tabId: string): number {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
function candidateEventIndex(task: Task, tabId: string): number {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
return (tab?.transcript.length ?? 0) + 1;
}
const mockWorkbenchClients = new Map<string, HandoffWorkbenchClient>();
const mockWorkbenchClients = new Map<string, TaskWorkbenchClient>();
export function getMockWorkbenchClient(workspaceId = "default"): HandoffWorkbenchClient {
export function getMockWorkbenchClient(workspaceId = "default"): TaskWorkbenchClient {
let client = mockWorkbenchClients.get(workspaceId);
if (!client) {
client = new MockWorkbenchStore(workspaceId);

View file

@ -20,7 +20,7 @@ class RemoteFactoryAppStore implements FactoryAppClient {
};
private readonly listeners = new Set<() => void>();
private refreshPromise: Promise<void> | null = null;
private importPollTimeout: ReturnType<typeof setTimeout> | null = null;
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteFactoryAppClientOptions) {
this.backend = options.backend;
@ -39,9 +39,8 @@ class RemoteFactoryAppStore implements FactoryAppClient {
}
async signInWithGithub(userId?: string): Promise<void> {
this.snapshot = await this.backend.signInWithGithub(userId);
this.notify();
this.scheduleImportPollingIfNeeded();
void userId;
await this.backend.signInWithGithub();
}
async signOut(): Promise<void> {
@ -52,7 +51,7 @@ class RemoteFactoryAppStore implements FactoryAppClient {
async selectOrganization(organizationId: string): Promise<void> {
this.snapshot = await this.backend.selectAppOrganization(organizationId);
this.notify();
this.scheduleImportPollingIfNeeded();
this.scheduleSyncPollingIfNeeded();
}
async updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<void> {
@ -60,15 +59,18 @@ class RemoteFactoryAppStore implements FactoryAppClient {
this.notify();
}
async triggerRepoImport(organizationId: string): Promise<void> {
async triggerGithubSync(organizationId: string): Promise<void> {
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
this.notify();
this.scheduleImportPollingIfNeeded();
this.scheduleSyncPollingIfNeeded();
}
async completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void> {
this.snapshot = await this.backend.completeAppHostedCheckout(organizationId, planId);
this.notify();
await this.backend.completeAppHostedCheckout(organizationId, planId);
}
async openBillingPortal(organizationId: string): Promise<void> {
await this.backend.openAppBillingPortal(organizationId);
}
async cancelScheduledRenewal(organizationId: string): Promise<void> {
@ -82,8 +84,7 @@ class RemoteFactoryAppStore implements FactoryAppClient {
}
async reconnectGithub(organizationId: string): Promise<void> {
this.snapshot = await this.backend.reconnectAppGithub(organizationId);
this.notify();
await this.backend.reconnectAppGithub(organizationId);
}
async recordSeatUsage(workspaceId: string): Promise<void> {
@ -91,18 +92,18 @@ class RemoteFactoryAppStore implements FactoryAppClient {
this.notify();
}
private scheduleImportPollingIfNeeded(): void {
if (this.importPollTimeout) {
clearTimeout(this.importPollTimeout);
this.importPollTimeout = null;
private scheduleSyncPollingIfNeeded(): void {
if (this.syncPollTimeout) {
clearTimeout(this.syncPollTimeout);
this.syncPollTimeout = null;
}
if (!this.snapshot.organizations.some((organization) => organization.repoImportStatus === "importing")) {
if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) {
return;
}
this.importPollTimeout = setTimeout(() => {
this.importPollTimeout = null;
this.syncPollTimeout = setTimeout(() => {
this.syncPollTimeout = null;
void this.refresh();
}, 500);
}
@ -116,7 +117,7 @@ class RemoteFactoryAppStore implements FactoryAppClient {
this.refreshPromise = (async () => {
this.snapshot = await this.backend.getAppSnapshot();
this.notify();
this.scheduleImportPollingIfNeeded();
this.scheduleSyncPollingIfNeeded();
})().finally(() => {
this.refreshPromise = null;
});

View file

@ -1,31 +1,31 @@
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
TaskWorkbenchSnapshot,
} from "@sandbox-agent/factory-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchProjects } from "../workbench-model.js";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
import { groupWorkbenchRepos } from "../workbench-model.js";
import type { TaskWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
workspaceId: string;
}
class RemoteWorkbenchStore implements HandoffWorkbenchClient {
class RemoteWorkbenchStore implements TaskWorkbenchClient {
private readonly backend: BackendClient;
private readonly workspaceId: string;
private snapshot: HandoffWorkbenchSnapshot;
private snapshot: TaskWorkbenchSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkbench: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
@ -37,12 +37,12 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
this.snapshot = {
workspaceId: options.workspaceId,
repos: [],
projects: [],
handoffs: [],
repoSections: [],
tasks: [],
};
}
getSnapshot(): HandoffWorkbenchSnapshot {
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
@ -62,85 +62,85 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
};
}
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
const created = await this.backend.createWorkbenchHandoff(this.workspaceId, input);
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const created = await this.backend.createWorkbenchTask(this.workspaceId, input);
await this.refresh();
return created;
}
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
async markTaskUnread(input: TaskWorkbenchSelectInput): 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);
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchTask(this.workspaceId, input);
await this.refresh();
}
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
async renameBranch(input: TaskWorkbenchRenameInput): 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");
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.workspaceId, input);
await this.refresh();
}
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.handoffId, "push");
async pushTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.taskId, "push");
await this.refresh();
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();
}
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
await this.refresh();
}
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.backend.recordAppSeatUsage(this.workspaceId);
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
await this.refresh();
}
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.workspaceId, input);
await this.refresh();
}
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
const created = await this.backend.createWorkbenchSession(this.workspaceId, input);
await this.refresh();
return created;
}
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
await this.backend.changeWorkbenchModel(this.workspaceId, input);
await this.refresh();
}
@ -185,7 +185,8 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
}
this.snapshot = {
...nextSnapshot,
projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
repoSections: nextSnapshot.repoSections ?? groupWorkbenchRepos(nextSnapshot.repos, nextSnapshot.tasks),
tasks: nextSnapshot.tasks,
};
for (const listener of [...this.listeners]) {
listener();
@ -200,6 +201,6 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
export function createRemoteWorkbenchClient(
options: RemoteWorkbenchClientOptions,
): HandoffWorkbenchClient {
): TaskWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -1,4 +1,4 @@
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/factory-shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",
@ -9,9 +9,9 @@ export const HANDOFF_STATUS_GROUPS = [
"error"
] as const;
export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
export type TaskStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
const QUEUED_STATUSES = new Set<HandoffStatus>([
const QUEUED_STATUSES = new Set<TaskStatus>([
"init_bootstrap_db",
"init_enqueue_provision",
"init_ensure_name",
@ -30,7 +30,7 @@ const QUEUED_STATUSES = new Set<HandoffStatus>([
"kill_finalize"
]);
export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup {
export function groupTaskStatus(status: TaskStatus): TaskStatusGroup {
if (status === "running") return "running";
if (status === "idle") return "idle";
if (status === "archived") return "archived";
@ -40,7 +40,7 @@ export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup {
return "queued";
}
function emptyStatusCounts(): Record<HandoffStatusGroup, number> {
function emptyStatusCounts(): Record<TaskStatusGroup, number> {
return {
queued: 0,
running: 0,
@ -51,9 +51,9 @@ function emptyStatusCounts(): Record<HandoffStatusGroup, number> {
};
}
export interface HandoffSummary {
export interface TaskSummary {
total: number;
byStatus: Record<HandoffStatusGroup, number>;
byStatus: Record<TaskStatusGroup, number>;
byProvider: Record<string, number>;
}
@ -71,7 +71,7 @@ export function fuzzyMatch(target: string, query: string): boolean {
return true;
}
export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRecord[] {
export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
const q = query.trim();
if (!q) {
return rows;
@ -81,7 +81,7 @@ export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRec
const fields = [
row.branchName ?? "",
row.title ?? "",
row.handoffId,
row.taskId,
row.task,
row.prAuthor ?? "",
row.reviewer ?? ""
@ -101,12 +101,12 @@ export function formatRelativeAge(updatedAt: number, now = Date.now()): string {
return `${days}d`;
}
export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary {
export function summarizeTasks(rows: TaskRecord[]): TaskSummary {
const byStatus = emptyStatusCounts();
const byProvider: Record<string, number> = {};
for (const row of rows) {
byStatus[groupHandoffStatus(row.status)] += 1;
byStatus[groupTaskStatus(row.status)] += 1;
byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1;
}

View file

@ -1,63 +1,63 @@
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/factory-shared";
import type { BackendClient } from "./backend-client.js";
import { getMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type HandoffWorkbenchClientMode = "mock" | "remote";
export type TaskWorkbenchClientMode = "mock" | "remote";
export interface CreateHandoffWorkbenchClientOptions {
mode: HandoffWorkbenchClientMode;
export interface CreateTaskWorkbenchClientOptions {
mode: TaskWorkbenchClientMode;
backend?: BackendClient;
workspaceId?: string;
}
export interface HandoffWorkbenchClient {
getSnapshot(): HandoffWorkbenchSnapshot;
export interface TaskWorkbenchClient {
getSnapshot(): TaskWorkbenchSnapshot;
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>;
pushHandoff(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>;
createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameTask(input: TaskWorkbenchRenameInput): Promise<void>;
renameBranch(input: TaskWorkbenchRenameInput): Promise<void>;
archiveTask(input: TaskWorkbenchSelectInput): Promise<void>;
publishPr(input: TaskWorkbenchSelectInput): Promise<void>;
pushTask(input: TaskWorkbenchSelectInput): Promise<void>;
revertFile(input: TaskWorkbenchDiffInput): Promise<void>;
updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkbenchTabInput): Promise<void>;
setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
closeTab(input: TaskWorkbenchTabInput): Promise<void>;
addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse>;
changeModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
}
export function createHandoffWorkbenchClient(
options: CreateHandoffWorkbenchClientOptions,
): HandoffWorkbenchClient {
export function createTaskWorkbenchClient(
options: CreateTaskWorkbenchClientOptions,
): TaskWorkbenchClient {
if (options.mode === "mock") {
return getMockWorkbenchClient(options.workspaceId);
}
if (!options.backend) {
throw new Error("Remote handoff workbench client requires a backend client");
throw new Error("Remote task workbench client requires a backend client");
}
if (!options.workspaceId) {
throw new Error("Remote handoff workbench client requires a workspace id");
throw new Error("Remote task workbench client requires a workspace id");
}
return createRemoteWorkbenchClient({

View file

@ -3,13 +3,13 @@ import type {
WorkbenchAgentTab as AgentTab,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchHandoff as Handoff,
HandoffWorkbenchSnapshot,
WorkbenchTask as Task,
TaskWorkbenchSnapshot,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection,
WorkbenchRepoSection,
WorkbenchRepo,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/factory-shared";
@ -260,7 +260,7 @@ export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): F
});
}
export function buildInitialHandoffs(): Handoff[] {
export function buildInitialTasks(): Task[] {
return [
{
id: "h1",
@ -913,7 +913,7 @@ export function buildInitialHandoffs(): Handoff[] {
];
}
function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: string): Handoff[] {
function buildPersonalTasks(ownerName: string, repoId: string, repoName: string): Task[] {
return [
{
id: "h-personal-1",
@ -950,7 +950,7 @@ function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: stri
agent: "claude",
createdAtMs: minutesAgo(20),
lines: [
"Updated the hero copy to focus on speed-to-handoff and clearer user outcomes.",
"Updated the hero copy to focus on speed-to-task and clearer user outcomes.",
"",
"I also adjusted the primary CTA to feel more action-oriented.",
],
@ -966,10 +966,10 @@ function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: stri
diffs: {
"src/content/home.ts": [
"@@ -1,6 +1,9 @@",
"-export const heroHeadline = 'Build AI handoffs faster';",
"+export const heroHeadline = 'Ship clean handoffs without the chaos';",
"-export const heroHeadline = 'Build AI tasks faster';",
"+export const heroHeadline = 'Ship clean tasks without the chaos';",
" export const heroBody = [",
"- 'OpenHandoff keeps context, diffs, and follow-up work in one place.',",
"- 'OpenTask keeps context, diffs, and follow-up work in one place.',",
"+ 'Review work, keep context, and hand tasks across your team without losing the thread.',",
"+ 'Everything stays attached to the repo, the branch, and the transcript.',",
" ];",
@ -1000,7 +1000,7 @@ function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: stri
];
}
function buildRivetHandoffs(): Handoff[] {
function buildRivetTasks(): Task[] {
return [
{
id: "rivet-h1",
@ -1092,18 +1092,18 @@ function buildRivetHandoffs(): Handoff[] {
];
}
export function buildInitialMockLayoutViewModel(workspaceId = "default"): HandoffWorkbenchSnapshot {
export function buildInitialMockLayoutViewModel(workspaceId = "default"): TaskWorkbenchSnapshot {
let repos: WorkbenchRepo[];
let handoffs: Handoff[];
let tasks: Task[];
switch (workspaceId) {
case "personal-nathan":
repos = [{ id: "nathan-personal-site", label: "nathan/personal-site" }];
handoffs = buildPersonalHandoffs("Nathan", "nathan-personal-site", "nathan/personal-site");
tasks = buildPersonalTasks("Nathan", "nathan-personal-site", "nathan/personal-site");
break;
case "personal-jamie":
repos = [{ id: "jamie-demo-app", label: "jamie/demo-app" }];
handoffs = buildPersonalHandoffs("Jamie", "jamie-demo-app", "jamie/demo-app");
tasks = buildPersonalTasks("Jamie", "jamie-demo-app", "jamie/demo-app");
break;
case "rivet":
repos = [
@ -1112,7 +1112,7 @@ export function buildInitialMockLayoutViewModel(workspaceId = "default"): Handof
{ id: "rivet-billing", label: "rivet/billing" },
{ id: "rivet-infrastructure", label: "rivet/infrastructure" },
];
handoffs = buildRivetHandoffs();
tasks = buildRivetTasks();
break;
case "acme":
case "default":
@ -1122,49 +1122,54 @@ export function buildInitialMockLayoutViewModel(workspaceId = "default"): Handof
{ id: "acme-frontend", label: "acme/frontend" },
{ id: "acme-infra", label: "acme/infra" },
];
handoffs = buildInitialHandoffs();
tasks = buildInitialTasks();
break;
}
return {
workspaceId,
repos,
projects: groupWorkbenchProjects(repos, handoffs),
handoffs,
repoSections: groupWorkbenchRepos(repos, tasks),
tasks,
};
}
export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff[]): WorkbenchProjectSection[] {
const grouped = new Map<string, WorkbenchProjectSection>();
export function groupWorkbenchRepos(repos: WorkbenchRepo[], tasks: Task[]): WorkbenchRepoSection[] {
const grouped = new Map<string, WorkbenchRepoSection>();
for (const repo of repos) {
grouped.set(repo.id, {
id: repo.id,
label: repo.label,
updatedAtMs: 0,
handoffs: [],
tasks: [],
});
}
for (const handoff of handoffs) {
const existing = grouped.get(handoff.repoId) ?? {
id: handoff.repoId,
label: handoff.repoName,
updatedAtMs: 0,
handoffs: [],
};
for (const task of tasks) {
const linkedRepoIds = task.repoIds?.length ? task.repoIds : [task.repoId];
for (const repoId of linkedRepoIds) {
const existing = grouped.get(repoId) ?? {
id: repoId,
label: repoId === task.repoId ? task.repoName : repoId,
updatedAtMs: 0,
tasks: [],
};
existing.handoffs.push(handoff);
existing.updatedAtMs = Math.max(existing.updatedAtMs, handoff.updatedAtMs);
grouped.set(handoff.repoId, existing);
existing.tasks.push(task);
existing.updatedAtMs = Math.max(existing.updatedAtMs, task.updatedAtMs);
grouped.set(repoId, existing);
}
}
return [...grouped.values()]
.map((project) => ({
...project,
handoffs: [...project.handoffs].sort((a, b) => b.updatedAtMs - a.updatedAtMs),
.map((repoSection) => ({
...repoSection,
tasks: [...repoSection.tasks].sort((a, b) => b.updatedAtMs - a.updatedAtMs),
updatedAtMs:
project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs,
repoSection.tasks.length > 0
? Math.max(...repoSection.tasks.map((task) => task.updatedAtMs))
: repoSection.updatedAtMs,
}))
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";
@ -79,20 +79,20 @@ function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
}
}
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, handoffId: string): Promise<string> {
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, taskId: string): Promise<string> {
try {
const handoff = await client.getHandoff(workspaceId, handoffId);
const history = await client.listHistory({ workspaceId, handoffId, limit: 80 }).catch(() => []);
const task = await client.getTask(workspaceId, taskId);
const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`)
.join("\n");
let sessionEventsSummary = "";
if (handoff.activeSandboxId && handoff.activeSessionId) {
if (task.activeSandboxId && task.activeSessionId) {
const events = await client
.listSandboxSessionEvents(workspaceId, handoff.providerId, handoff.activeSandboxId, {
sessionId: handoff.activeSessionId,
.listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, {
sessionId: task.activeSessionId,
limit: 50,
})
.then((r) => r.items)
@ -104,17 +104,17 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, workspa
}
return [
"=== handoff ===",
"=== task ===",
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,
status: task.status,
statusMessage: task.statusMessage,
title: task.title,
branchName: task.branchName,
activeSandboxId: task.activeSandboxId,
activeSessionId: task.activeSessionId,
prUrl: task.prUrl,
prSubmitted: task.prSubmitted,
},
null,
2
@ -144,7 +144,7 @@ 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",
"creates a task, waits for agent to implement, and opens a PR",
{ timeout: 15 * 60_000 },
async () => {
const endpoint =
@ -164,7 +164,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
const repo = await client.addRepo(workspaceId, repoRemote);
const created = await client.createHandoff({
const created = await client.createTask({
workspaceId,
repoId: repo.repoId,
task: [
@ -187,42 +187,42 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
let lastStatus: string | null = null;
try {
const namedAndProvisioned = await poll<HandoffRecord>(
"handoff naming + sandbox provisioning",
const namedAndProvisioned = await poll<TaskRecord>(
"task naming + sandbox provisioning",
// Cold Daytona snapshot/image preparation can exceed 5 minutes on first run.
8 * 60_000,
1_000,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
lastStatus = h.status;
}
if (h.status === "error") {
throw new Error("handoff entered error state during provisioning");
throw new Error("task entered error state during provisioning");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<HandoffRecord>(
"handoff to create active session",
const withSession = await poll<TaskRecord>(
"task to create active session",
3 * 60_000,
1_500,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
throw new Error("handoff entered error state while waiting for active session");
throw new Error("task entered error state while waiting for active session");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -241,23 +241,23 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
).items,
(events) => events.length > 0
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
await poll<HandoffRecord>(
"handoff to reach idle state",
await poll<TaskRecord>(
"task to reach idle state",
8 * 60_000,
2_000,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
throw new Error("handoff entered error state while waiting for idle");
throw new Error("task entered error state while waiting for idle");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -265,14 +265,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
"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")
async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }),
(events) => events.some((e) => e.kind === "task.pr_created")
)
.catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "handoff.pr_created")!);
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
const payload = parseHistoryPayload(prCreatedEvent);
prNumber = Number(payload.prNumber);
@ -293,17 +293,17 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
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");
// Close the task and assert the sandbox is released (stopped).
await client.runAction(workspaceId, created.taskId, "archive");
await poll<HandoffRecord>(
"handoff to become archived (session released)",
await poll<TaskRecord>(
"task to become archived (session released)",
60_000,
1_000,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => h.status === "archived" && h.activeSessionId === null
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -318,7 +318,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
return st.includes("stopped") || st.includes("suspended") || st.includes("paused");
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
const state = await client
.sandboxProviderState(workspaceId, "daytona", sandboxId!)
.catch(() => null);

View file

@ -3,10 +3,10 @@ import { mkdir, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import type {
HandoffRecord,
HandoffWorkbenchSnapshot,
TaskRecord,
TaskWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchTask,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@sandbox-agent/factory-shared";
@ -76,18 +76,18 @@ async function resolveBackendContainerName(endpoint: string): Promise<string | n
return containerName ?? null;
}
function sandboxRepoPath(record: HandoffRecord): string {
function sandboxRepoPath(record: TaskRecord): string {
const activeSandbox =
record.sandboxes.find((sandbox) => sandbox.sandboxId === record.activeSandboxId) ??
record.sandboxes.find((sandbox) => typeof sandbox.cwd === "string" && sandbox.cwd.length > 0);
const cwd = activeSandbox?.cwd?.trim();
if (!cwd) {
throw new Error(`No sandbox cwd is available for handoff ${record.handoffId}`);
throw new Error(`No sandbox cwd is available for task ${record.taskId}`);
}
return cwd;
}
async function seedSandboxFile(endpoint: string, record: HandoffRecord, filePath: string, content: string): Promise<void> {
async function seedSandboxFile(endpoint: string, record: TaskRecord, filePath: string, content: string): Promise<void> {
const repoPath = sandboxRepoPath(record);
const containerName = await resolveBackendContainerName(endpoint);
if (!containerName) {
@ -128,18 +128,18 @@ async function poll<T>(
}
}
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`);
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
}
return handoff;
return task;
}
function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`);
throw new Error(`tab ${tabId} missing from task ${task.id}`);
}
return tab;
}
@ -218,7 +218,7 @@ function transcriptIncludesAgentText(
describe("e2e(client): workbench flows", () => {
it.skipIf(!RUN_WORKBENCH_E2E)(
"creates a handoff, adds sessions, exchanges messages, and manages workbench state",
"creates a task, adds sessions, exchanges messages, and manages workbench state",
{ timeout: 20 * 60_000 },
async () => {
const endpoint =
@ -237,7 +237,7 @@ describe("e2e(client): workbench flows", () => {
});
const repo = await client.addRepo(workspaceId, repoRemote);
const created = await client.createWorkbenchHandoff(workspaceId, {
const created = await client.createWorkbenchTask(workspaceId, {
repoId: repo.repoId,
title: `Workbench E2E ${runId}`,
branch: `e2e/${runId}`,
@ -246,11 +246,11 @@ describe("e2e(client): workbench flows", () => {
});
const provisioned = await poll(
"handoff provisioning",
"task provisioning",
12 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => handoff.branch === `e2e/${runId}` && handoff.tabs.length > 0,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => task.branch === `e2e/${runId}` && task.tabs.length > 0,
);
const primaryTab = provisioned.tabs[0]!;
@ -259,11 +259,11 @@ describe("e2e(client): workbench flows", () => {
"initial agent response",
12 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, primaryTab.id);
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, primaryTab.id);
return (
handoff.status === "idle" &&
task.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, expectedInitialReply)
);
@ -273,41 +273,41 @@ describe("e2e(client): workbench flows", () => {
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
const detail = await client.getHandoff(workspaceId, created.handoffId);
const detail = await client.getTask(workspaceId, created.taskId);
await seedSandboxFile(endpoint, detail, expectedFile, runId);
const fileSeeded = await poll(
"seeded sandbox file reflected in workbench",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => handoff.fileChanges.some((file) => file.path === expectedFile),
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => task.fileChanges.some((file) => file.path === expectedFile),
);
expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
await client.renameWorkbenchHandoff(workspaceId, {
handoffId: created.handoffId,
await client.renameWorkbenchTask(workspaceId, {
taskId: created.taskId,
value: `Workbench E2E ${runId} Renamed`,
});
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: primaryTab.id,
title: "Primary Session",
});
const secondTab = await client.createWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
model,
});
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
title: "Follow-up Session",
});
await client.updateWorkbenchDraft(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [
@ -320,12 +320,12 @@ describe("e2e(client): workbench flows", () => {
],
});
const drafted = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId);
expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply);
expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [],
@ -335,9 +335,9 @@ describe("e2e(client): workbench flows", () => {
"follow-up session response",
10 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, secondTab.tabId);
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, secondTab.tabId);
return (
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, expectedReply)
@ -349,17 +349,17 @@ describe("e2e(client): workbench flows", () => {
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
await client.setWorkbenchSessionUnread(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
unread: false,
});
await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId });
await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId });
const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId);
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
await client.closeWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
});
@ -367,13 +367,13 @@ describe("e2e(client): workbench flows", () => {
"secondary session closed",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => !handoff.tabs.some((tab) => tab.id === secondTab.tabId),
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => !task.tabs.some((tab) => tab.id === secondTab.tabId),
);
expect(closedSnapshot.tabs).toHaveLength(1);
await client.revertWorkbenchFile(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
path: expectedFile,
});
@ -381,8 +381,8 @@ describe("e2e(client): workbench flows", () => {
"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),
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
);
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type {
HandoffWorkbenchSnapshot,
TaskWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchTask,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@sandbox-agent/factory-shared";
@ -70,18 +70,18 @@ async function poll<T>(
}
}
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`);
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
}
return handoff;
return task;
}
function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`);
throw new Error(`tab ${tabId} missing from task ${task.id}`);
}
return tab;
}
@ -156,12 +156,12 @@ async function measureWorkbenchSnapshot(
avgMs: number;
maxMs: number;
payloadBytes: number;
handoffCount: number;
taskCount: number;
tabCount: number;
transcriptEventCount: number;
}> {
const durations: number[] = [];
let snapshot: HandoffWorkbenchSnapshot | null = null;
let snapshot: TaskWorkbenchSnapshot | null = null;
for (let index = 0; index < iterations; index += 1) {
const startedAt = performance.now();
@ -173,13 +173,13 @@ async function measureWorkbenchSnapshot(
workspaceId,
repos: [],
projects: [],
handoffs: [],
tasks: [],
};
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),
const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0);
const transcriptEventCount = finalSnapshot.tasks.reduce(
(sum, task) =>
sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
0,
);
@ -187,7 +187,7 @@ async function measureWorkbenchSnapshot(
avgMs: Math.round(average(durations)),
maxMs: Math.round(Math.max(...durations, 0)),
payloadBytes,
handoffCount: finalSnapshot.handoffs.length,
taskCount: finalSnapshot.tasks.length,
tabCount,
transcriptEventCount,
};
@ -202,7 +202,7 @@ describe("e2e(client): workbench load", () => {
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const taskCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
@ -212,12 +212,12 @@ describe("e2e(client): workbench load", () => {
});
const repo = await client.addRepo(workspaceId, repoRemote);
const createHandoffLatencies: number[] = [];
const createTaskLatencies: number[] = [];
const provisionLatencies: number[] = [];
const createSessionLatencies: number[] = [];
const messageRoundTripLatencies: number[] = [];
const snapshotSeries: Array<{
handoffCount: number;
taskCount: number;
avgMs: number;
maxMs: number;
payloadBytes: number;
@ -227,31 +227,31 @@ describe("e2e(client): workbench load", () => {
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
for (let handoffIndex = 0; handoffIndex < handoffCount; handoffIndex += 1) {
const runId = `load-${handoffIndex}-${Date.now().toString(36)}`;
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
const initialReply = `LOAD_INIT_${runId}`;
const createStartedAt = performance.now();
const created = await client.createWorkbenchHandoff(workspaceId, {
const created = await client.createWorkbenchTask(workspaceId, {
repoId: repo.repoId,
title: `Workbench Load ${runId}`,
branch: `load/${runId}`,
model,
task: `Reply with exactly: ${initialReply}`,
});
createHandoffLatencies.push(performance.now() - createStartedAt);
createTaskLatencies.push(performance.now() - createStartedAt);
const provisionStartedAt = performance.now();
const provisioned = await poll(
`handoff ${runId} provisioning`,
`task ${runId} provisioning`,
12 * 60_000,
pollIntervalMs,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = handoff.tabs[0];
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = task.tabs[0];
return Boolean(
tab &&
handoff.status === "idle" &&
task.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, initialReply),
);
@ -267,13 +267,13 @@ describe("e2e(client): workbench load", () => {
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
const createSessionStartedAt = performance.now();
const createdSession = await client.createWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
model,
});
createSessionLatencies.push(performance.now() - createSessionStartedAt);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: createdSession.tabId,
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
attachments: [],
@ -281,12 +281,12 @@ describe("e2e(client): workbench load", () => {
const messageStartedAt = performance.now();
const withReply = await poll(
`handoff ${runId} session ${sessionIndex} reply`,
`task ${runId} session ${sessionIndex} reply`,
10 * 60_000,
pollIntervalMs,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, createdSession.tabId);
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, createdSession.tabId);
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
},
);
@ -300,7 +300,7 @@ describe("e2e(client): workbench load", () => {
console.info(
"[workbench-load-snapshot]",
JSON.stringify({
handoffIndex: handoffIndex + 1,
taskIndex: taskIndex + 1,
...snapshotMetrics,
}),
);
@ -309,9 +309,9 @@ describe("e2e(client): workbench load", () => {
const firstSnapshot = snapshotSeries[0]!;
const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!;
const summary = {
handoffCount,
taskCount,
extraSessionCount,
createHandoffAvgMs: Math.round(average(createHandoffLatencies)),
createTaskAvgMs: Math.round(average(createTaskLatencies)),
provisionAvgMs: Math.round(average(provisionLatencies)),
createSessionAvgMs: Math.round(average(createSessionLatencies)),
messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)),
@ -326,10 +326,10 @@ describe("e2e(client): workbench load", () => {
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);
expect(createTaskLatencies.length).toBe(taskCount);
expect(provisionLatencies.length).toBe(taskCount);
expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount);
},
);
});

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
handoffKey,
handoffStatusSyncKey,
taskKey,
taskStatusSyncKey,
historyKey,
projectBranchSyncKey,
projectKey,
projectPrSyncKey,
repoBranchSyncKey,
repoKey,
repoPrSyncKey,
sandboxInstanceKey,
workspaceKey
} from "../src/keys.js";
@ -14,13 +14,13 @@ describe("actor keys", () => {
it("prefixes every key with workspace namespace", () => {
const keys = [
workspaceKey("default"),
projectKey("default", "repo"),
handoffKey("default", "repo", "handoff"),
repoKey("default", "repo"),
taskKey("default", "task"),
sandboxInstanceKey("default", "daytona", "sbx"),
historyKey("default", "repo"),
projectPrSyncKey("default", "repo"),
projectBranchSyncKey("default", "repo"),
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
repoPrSyncKey("default", "repo"),
repoBranchSyncKey("default", "repo"),
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
];
for (const key of keys) {

View file

@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import type { TaskRecord } from "@sandbox-agent/factory-shared";
import {
filterHandoffs,
filterTasks,
formatRelativeAge,
fuzzyMatch,
summarizeHandoffs
summarizeTasks
} from "../src/view-model.js";
const sample: HandoffRecord = {
const sample: TaskRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
handoffId: "handoff-1",
taskId: "task-1",
branchName: "feature/test",
title: "Test Title",
task: "Do test",
@ -53,19 +53,19 @@ describe("search helpers", () => {
});
it("filters rows across branch and title", () => {
const rows: HandoffRecord[] = [
const rows: TaskRecord[] = [
sample,
{
...sample,
handoffId: "handoff-2",
taskId: "task-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);
expect(filterTasks(rows, "doc")).toHaveLength(1);
expect(filterTasks(rows, "h2")).toHaveLength(1);
expect(filterTasks(rows, "test")).toHaveLength(2);
});
});
@ -76,13 +76,13 @@ describe("summary helpers", () => {
});
it("summarizes by status and provider", () => {
const rows: HandoffRecord[] = [
const rows: TaskRecord[] = [
sample,
{ ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" },
{ ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" }
{ ...sample, taskId: "task-2", status: "idle", providerId: "daytona" },
{ ...sample, taskId: "task-3", status: "error", providerId: "daytona" }
];
const summary = summarizeHandoffs(rows);
const summary = summarizeTasks(rows);
expect(summary.total).toBe(3);
expect(summary.byStatus.running).toBe(1);
expect(summary.byStatus.idle).toBe(1);

View file

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