mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 20:01:27 +00:00
factory: rename project and handoff actors
This commit is contained in:
parent
3022bce2ad
commit
ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue