feat(foundry): checkpoint actor and workspace refactor

This commit is contained in:
Nathan Flurry 2026-03-15 10:20:27 -07:00
parent 32f3c6c3bc
commit dbe57d45b9
81 changed files with 3441 additions and 2332 deletions

View file

@ -10,8 +10,8 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts",
"test:e2e:workbench": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workbench-e2e.test.ts",
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts"
"test:e2e:workspace": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workspace-e2e.test.ts",
"test:e2e:workspace-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workspace-load-e2e.test.ts"
},
"dependencies": {
"@sandbox-agent/foundry-shared": "workspace:*",

View file

@ -4,6 +4,7 @@ import type {
FoundryOrganization,
FoundryUser,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getMockFoundryAppClient } from "./mock-app.js";
@ -17,6 +18,7 @@ export interface FoundryAppClient {
skipStarterRepo(): Promise<void>;
starStarterRepo(organizationId: string): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
setDefaultModel(model: WorkspaceModelId): Promise<void>;
updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;

View file

@ -10,25 +10,25 @@ import type {
SandboxProcessesEvent,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
TaskEvent,
WorkbenchTaskDetail,
WorkbenchTaskSummary,
WorkbenchSessionDetail,
WorkspaceTaskDetail,
WorkspaceTaskSummary,
WorkspaceSessionDetail,
OrganizationEvent,
OrganizationSummarySnapshot,
HistoryEvent,
AuditLogEvent as HistoryEvent,
HistoryQueryInput,
SandboxProviderId,
RepoOverview,
@ -37,6 +37,7 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import { createMockBackendClient } from "./mock/backend-client.js";
@ -78,39 +79,36 @@ interface OrganizationHandle {
createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(input: { organizationId: string; repoId?: string }): Promise<TaskSummary[]>;
getRepoOverview(input: { organizationId: string; repoId: string }): Promise<RepoOverview>;
history(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(taskId: string): Promise<SwitchResult>;
getTask(input: { organizationId: string; taskId: string }): Promise<TaskRecord>;
attachTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>;
syncTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>;
mergeTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>;
archiveTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>;
killTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>;
auditLog(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(input: { repoId: string; taskId: string }): Promise<SwitchResult>;
getTask(input: { organizationId: string; repoId: string; taskId: string }): Promise<TaskRecord>;
attachTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
syncTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
mergeTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
archiveTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
killTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getOrganizationSummary(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
applyTaskSummaryUpdate(input: { taskSummary: WorkbenchTaskSummary }): Promise<void>;
removeTaskSummary(input: { taskId: string }): Promise<void>;
reconcileWorkbenchState(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
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<{ sessionId: 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: TaskWorkbenchSessionInput): Promise<void>;
closeWorkbenchSession(input: TaskWorkbenchSessionInput): Promise<void>;
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
reloadGithubOrganization(): Promise<void>;
reloadGithubPullRequests(): Promise<void>;
reloadGithubRepository(input: { repoId: string }): Promise<void>;
reloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
adminReconcileWorkspaceState(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
createWorkspaceTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(input: TaskWorkspaceSelectInput): Promise<void>;
renameWorkspaceTask(input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
adminReloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
}
interface AppOrganizationHandle {
@ -119,6 +117,7 @@ interface AppOrganizationHandle {
skipAppStarterRepo(input: { sessionId: string }): Promise<FoundryAppSnapshot>;
starAppStarterRepo(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
selectAppOrganization(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
setAppDefaultModel(input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput & { sessionId: string }): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
beginAppGithubInstall(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>;
@ -130,9 +129,9 @@ interface AppOrganizationHandle {
}
interface TaskHandle {
getTaskSummary(): Promise<WorkbenchTaskSummary>;
getTaskDetail(): Promise<WorkbenchTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkbenchSessionDetail>;
getTaskSummary(): Promise<WorkspaceTaskSummary>;
getTaskDetail(): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkspaceSessionDetail>;
connect(): ActorConn;
}
@ -192,6 +191,7 @@ export interface BackendClient {
skipAppStarterRepo(): Promise<FoundryAppSnapshot>;
starAppStarterRepo(organizationId: string): Promise<FoundryAppSnapshot>;
selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot>;
setAppDefaultModel(defaultModel: WorkspaceModelId): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot>;
reconnectAppGithub(organizationId: string): Promise<void>;
@ -204,11 +204,11 @@ export interface BackendClient {
createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(organizationId: string, repoId?: string): Promise<TaskSummary[]>;
getRepoOverview(organizationId: string, repoId: string): Promise<RepoOverview>;
getTask(organizationId: string, taskId: string): Promise<TaskRecord>;
getTask(organizationId: string, repoId: string, taskId: string): Promise<TaskRecord>;
listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(organizationId: string, taskId: string): Promise<SwitchResult>;
attachTask(organizationId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(organizationId: string, taskId: string, action: TaskAction): Promise<void>;
switchTask(organizationId: string, repoId: string, taskId: string): Promise<SwitchResult>;
attachTask(organizationId: string, repoId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(organizationId: string, repoId: string, taskId: string, action: TaskAction): Promise<void>;
createSandboxSession(input: {
organizationId: string;
sandboxProviderId: SandboxProviderId;
@ -280,28 +280,27 @@ export interface BackendClient {
): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>;
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkbenchTaskDetail>;
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail>;
getWorkbench(organizationId: string): Promise<TaskWorkbenchSnapshot>;
subscribeWorkbench(organizationId: string, listener: () => void): () => void;
createWorkbenchTask(organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void>;
renameWorkbenchTask(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkbenchSession(organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
updateWorkbenchDraft(organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void>;
closeWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void>;
publishWorkbenchPr(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(organizationId: string, input: TaskWorkbenchDiffInput): Promise<void>;
reloadGithubOrganization(organizationId: string): Promise<void>;
reloadGithubPullRequests(organizationId: string): Promise<void>;
reloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
reloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void>;
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTaskDetail>;
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail>;
getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot>;
subscribeWorkspace(organizationId: string, listener: () => void): () => void;
createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void>;
setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void>;
sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void>;
stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
adminReloadGithubOrganization(organizationId: string): Promise<void>;
adminReloadGithubPullRequests(organizationId: string): Promise<void>;
adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void>;
health(): Promise<{ ok: true }>;
useOrganization(organizationId: string): Promise<{ organizationId: string }>;
starSandboxAgentRepo(organizationId: string): Promise<StarSandboxAgentRepoResult>;
@ -410,7 +409,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const rivetApiEndpoint = endpoints.rivetEndpoint;
const appApiEndpoint = endpoints.appEndpoint;
const client = createClient({ endpoint: rivetApiEndpoint }) as unknown as RivetClient;
const workbenchSubscriptions = new Map<
const workspaceSubscriptions = new Map<
string,
{
listeners: Set<() => void>;
@ -563,7 +562,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
};
const getWorkbenchCompat = async (organizationId: string): Promise<TaskWorkbenchSnapshot> => {
const getWorkspaceCompat = async (organizationId: string): Promise<TaskWorkspaceSnapshot> => {
const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId });
const tasks = (
await Promise.all(
@ -590,7 +589,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
}),
);
const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkbenchSessionDetail] => entry !== null));
const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkspaceSessionDetail] => entry !== null));
return {
id: detail.id,
repoId: detail.repoId,
@ -623,7 +622,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
};
}),
)
).filter((task): task is TaskWorkbenchSnapshot["tasks"][number] => task !== null);
).filter((task): task is TaskWorkspaceSnapshot["tasks"][number] => task !== null);
const repositories = summary.repos
.map((repo) => ({
@ -642,14 +641,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
};
};
const subscribeWorkbench = (organizationId: string, listener: () => void): (() => void) => {
let entry = workbenchSubscriptions.get(organizationId);
const subscribeWorkspace = (organizationId: string, listener: () => void): (() => void) => {
let entry = workspaceSubscriptions.get(organizationId);
if (!entry) {
entry = {
listeners: new Set(),
disposeConnPromise: null,
};
workbenchSubscriptions.set(organizationId, entry);
workspaceSubscriptions.set(organizationId, entry);
}
entry.listeners.add(listener);
@ -658,8 +657,8 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
entry.disposeConnPromise = (async () => {
const handle = await organization(organizationId);
const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("workbenchUpdated", () => {
const current = workbenchSubscriptions.get(organizationId);
const unsubscribeEvent = conn.on("organizationUpdated", () => {
const current = workspaceSubscriptions.get(organizationId);
if (!current) {
return;
}
@ -677,7 +676,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
return () => {
const current = workbenchSubscriptions.get(organizationId);
const current = workspaceSubscriptions.get(organizationId);
if (!current) {
return;
}
@ -686,7 +685,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return;
}
workbenchSubscriptions.delete(organizationId);
workspaceSubscriptions.delete(organizationId);
void current.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
@ -849,6 +848,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await (await appOrganization()).selectAppOrganization({ sessionId, organizationId });
},
async setAppDefaultModel(defaultModel: WorkspaceModelId): Promise<FoundryAppSnapshot> {
const sessionId = await getSessionId();
if (!sessionId) {
throw new Error("No active auth session");
}
return await (await appOrganization()).setAppDefaultModel({ sessionId, defaultModel });
},
async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> {
const sessionId = await getSessionId();
if (!sessionId) {
@ -948,33 +955,36 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await organization(organizationId)).getRepoOverview({ organizationId, repoId });
},
async getTask(organizationId: string, taskId: string): Promise<TaskRecord> {
async getTask(organizationId: string, repoId: string, taskId: string): Promise<TaskRecord> {
return (await organization(organizationId)).getTask({
organizationId,
repoId,
taskId,
});
},
async listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]> {
return (await organization(input.organizationId)).history(input);
return (await organization(input.organizationId)).auditLog(input);
},
async switchTask(organizationId: string, taskId: string): Promise<SwitchResult> {
return (await organization(organizationId)).switchTask(taskId);
async switchTask(organizationId: string, repoId: string, taskId: string): Promise<SwitchResult> {
return (await organization(organizationId)).switchTask({ repoId, taskId });
},
async attachTask(organizationId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
async attachTask(organizationId: string, repoId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
return (await organization(organizationId)).attachTask({
organizationId,
repoId,
taskId,
reason: "cli.attach",
});
},
async runAction(organizationId: string, taskId: string, action: TaskAction): Promise<void> {
async runAction(organizationId: string, repoId: string, taskId: string, action: TaskAction): Promise<void> {
if (action === "push") {
await (await organization(organizationId)).pushTask({
organizationId,
repoId,
taskId,
reason: "cli.push",
});
@ -983,6 +993,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (action === "sync") {
await (await organization(organizationId)).syncTask({
organizationId,
repoId,
taskId,
reason: "cli.sync",
});
@ -991,6 +1002,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (action === "merge") {
await (await organization(organizationId)).mergeTask({
organizationId,
repoId,
taskId,
reason: "cli.merge",
});
@ -999,6 +1011,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (action === "archive") {
await (await organization(organizationId)).archiveTask({
organizationId,
repoId,
taskId,
reason: "cli.archive",
});
@ -1006,6 +1019,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
await (await organization(organizationId)).killTask({
organizationId,
repoId,
taskId,
reason: "cli.kill",
});
@ -1160,92 +1174,88 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await organization(organizationId)).getOrganizationSummary({ organizationId });
},
async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkbenchTaskDetail> {
async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail();
},
async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkbenchSessionDetail> {
async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId });
},
async getWorkbench(organizationId: string): Promise<TaskWorkbenchSnapshot> {
return await getWorkbenchCompat(organizationId);
async getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot> {
return await getWorkspaceCompat(organizationId);
},
subscribeWorkbench(organizationId: string, listener: () => void): () => void {
return subscribeWorkbench(organizationId, listener);
subscribeWorkspace(organizationId: string, listener: () => void): () => void {
return subscribeWorkspace(organizationId, listener);
},
async createWorkbenchTask(organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
return (await organization(organizationId)).createWorkbenchTask(input);
async createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
return (await organization(organizationId)).createWorkspaceTask(input);
},
async markWorkbenchUnread(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await (await organization(organizationId)).markWorkbenchUnread(input);
async markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).markWorkspaceUnread(input);
},
async renameWorkbenchTask(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkbenchTask(input);
async renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceTask(input);
},
async renameWorkbenchBranch(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkbenchBranch(input);
async createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
return await (await organization(organizationId)).createWorkspaceSession(input);
},
async createWorkbenchSession(organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> {
return await (await organization(organizationId)).createWorkbenchSession(input);
async renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceSession(input);
},
async renameWorkbenchSession(organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> {
await (await organization(organizationId)).renameWorkbenchSession(input);
async setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await (await organization(organizationId)).setWorkspaceSessionUnread(input);
},
async setWorkbenchSessionUnread(organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await (await organization(organizationId)).setWorkbenchSessionUnread(input);
async updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await (await organization(organizationId)).updateWorkspaceDraft(input);
},
async updateWorkbenchDraft(organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await (await organization(organizationId)).updateWorkbenchDraft(input);
async changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await (await organization(organizationId)).changeWorkspaceModel(input);
},
async changeWorkbenchModel(organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
await (await organization(organizationId)).changeWorkbenchModel(input);
async sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await (await organization(organizationId)).sendWorkspaceMessage(input);
},
async sendWorkbenchMessage(organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
await (await organization(organizationId)).sendWorkbenchMessage(input);
async stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).stopWorkspaceSession(input);
},
async stopWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> {
await (await organization(organizationId)).stopWorkbenchSession(input);
async closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).closeWorkspaceSession(input);
},
async closeWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> {
await (await organization(organizationId)).closeWorkbenchSession(input);
async publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).publishWorkspacePr(input);
},
async publishWorkbenchPr(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await (await organization(organizationId)).publishWorkbenchPr(input);
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await (await organization(organizationId)).revertWorkspaceFile(input);
},
async revertWorkbenchFile(organizationId: string, input: TaskWorkbenchDiffInput): Promise<void> {
await (await organization(organizationId)).revertWorkbenchFile(input);
async adminReloadGithubOrganization(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubOrganization();
},
async reloadGithubOrganization(organizationId: string): Promise<void> {
await (await organization(organizationId)).reloadGithubOrganization();
async adminReloadGithubPullRequests(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequests();
},
async reloadGithubPullRequests(organizationId: string): Promise<void> {
await (await organization(organizationId)).reloadGithubPullRequests();
async adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubRepository({ repoId });
},
async reloadGithubRepository(organizationId: string, repoId: string): Promise<void> {
await (await organization(organizationId)).reloadGithubRepository({ repoId });
},
async reloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).reloadGithubPullRequest({ repoId, prNumber });
async adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequest({ repoId, prNumber });
},
async health(): Promise<{ ok: true }> {

View file

@ -8,4 +8,4 @@ export * from "./subscription/use-subscription.js";
export * from "./keys.js";
export * from "./mock-app.js";
export * from "./view-model.js";
export * from "./workbench-client.js";
export * from "./workspace-client.js";

View file

@ -16,6 +16,6 @@ export function taskSandboxKey(organizationId: string, sandboxId: string): Actor
return ["org", organizationId, "sandbox", sandboxId];
}
export function historyKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "history"];
export function auditLogKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "audit-log"];
}

View file

@ -1,4 +1,4 @@
import type { WorkbenchModelId } from "@sandbox-agent/foundry-shared";
import type { WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { injectMockLatency } from "./mock/latency.js";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -16,6 +16,7 @@ export interface MockFoundryUser {
githubLogin: string;
roleLabel: string;
eligibleOrganizationIds: string[];
defaultModel: WorkspaceModelId;
}
export interface MockFoundryOrganizationMember {
@ -61,7 +62,6 @@ export interface MockFoundryOrganizationSettings {
slug: string;
primaryDomain: string;
seatAccrualMode: "first_prompt";
defaultModel: WorkbenchModelId;
autoImportRepos: boolean;
}
@ -111,6 +111,7 @@ export interface MockFoundryAppClient {
skipStarterRepo(): Promise<void>;
starStarterRepo(organizationId: string): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
setDefaultModel(model: WorkspaceModelId): Promise<void>;
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
@ -180,7 +181,6 @@ function buildRivetOrganization(): MockFoundryOrganization {
slug: "rivet",
primaryDomain: "rivet.dev",
seatAccrualMode: "first_prompt",
defaultModel: "gpt-5.3-codex",
autoImportRepos: true,
},
github: {
@ -233,6 +233,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
defaultModel: "gpt-5.3-codex",
},
{
id: "user-maya",
@ -241,6 +242,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "maya",
roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"],
defaultModel: "claude-sonnet-4",
},
{
id: "user-jamie",
@ -249,6 +251,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "jamie",
roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"],
defaultModel: "claude-opus-4",
},
],
organizations: [
@ -261,7 +264,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
slug: "nathan",
primaryDomain: "personal",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
@ -297,7 +299,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
slug: "acme",
primaryDomain: "acme.dev",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
@ -342,7 +343,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
slug: "jamie",
primaryDomain: "personal",
seatAccrualMode: "first_prompt",
defaultModel: "claude-opus-4",
autoImportRepos: true,
},
github: {
@ -538,6 +538,18 @@ class MockFoundryAppStore implements MockFoundryAppClient {
}
}
async setDefaultModel(model: WorkspaceModelId): Promise<void> {
await this.injectAsyncLatency();
const currentUserId = this.snapshot.auth.currentUserId;
if (!currentUserId) {
throw new Error("No signed-in mock user");
}
this.updateSnapshot((current) => ({
...current,
users: current.users.map((user) => (user.id === currentUserId ? { ...user, defaultModel: model } : user)),
}));
}
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(input.organizationId);

View file

@ -6,25 +6,25 @@ import type {
SessionEvent,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
TaskEvent,
WorkbenchSessionDetail,
WorkbenchTaskDetail,
WorkbenchTaskSummary,
WorkspaceSessionDetail,
WorkspaceTaskDetail,
WorkspaceTaskSummary,
OrganizationEvent,
OrganizationSummarySnapshot,
HistoryEvent,
AuditLogEvent as HistoryEvent,
HistoryQueryInput,
SandboxProviderId,
RepoOverview,
@ -34,7 +34,7 @@ import type {
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import { getSharedMockWorkbenchClient } from "./workbench-client.js";
import { getSharedMockWorkspaceClient } from "./workspace-client.js";
interface MockProcessRecord extends SandboxProcessRecord {
logText: string;
@ -89,7 +89,7 @@ function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskReco
}
export function createMockBackendClient(defaultOrganizationId = "default"): BackendClient {
const workbench = getSharedMockWorkbenchClient();
const workspace = getSharedMockWorkspaceClient();
const listenersBySandboxId = new Map<string, Set<() => void>>();
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
const connectionListeners = new Map<string, Set<(payload: any) => void>>();
@ -97,7 +97,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
let nextProcessId = 1;
const requireTask = (taskId: string) => {
const task = workbench.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
const task = workspace.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`Unknown mock task ${taskId}`);
}
@ -164,7 +164,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
async dispose(): Promise<void> {},
});
const buildTaskSummary = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskSummary => ({
const buildTaskSummary = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskSummary => ({
id: task.id,
repoId: task.repoId,
title: task.title,
@ -187,7 +187,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
})),
});
const buildTaskDetail = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskDetail => ({
const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({
...buildTaskSummary(task),
task: task.title,
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
@ -211,7 +211,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
activeSandboxId: task.id,
});
const buildSessionDetail = (task: TaskWorkbenchSnapshot["tasks"][number], sessionId: string): WorkbenchSessionDetail => {
const buildSessionDetail = (task: TaskWorkspaceSnapshot["tasks"][number], sessionId: string): WorkspaceSessionDetail => {
const tab = task.sessions.find((candidate) => candidate.id === sessionId);
if (!tab) {
throw new Error(`Unknown mock session ${sessionId} for task ${task.id}`);
@ -232,7 +232,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
};
const buildOrganizationSummary = (): OrganizationSummarySnapshot => {
const snapshot = workbench.getSnapshot();
const snapshot = workspace.getSnapshot();
const taskSummaries = snapshot.tasks.map(buildTaskSummary);
return {
organizationId: defaultOrganizationId,
@ -256,20 +256,16 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
`sandbox:${organizationId}:${sandboxProviderId}:${sandboxId}`;
const emitOrganizationSnapshot = (): void => {
const summary = buildOrganizationSummary();
const latestTask = [...summary.taskSummaries].sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0] ?? null;
if (latestTask) {
emitConnectionEvent(organizationScope(defaultOrganizationId), "organizationUpdated", {
type: "taskSummaryUpdated",
taskSummary: latestTask,
} satisfies OrganizationEvent);
}
emitConnectionEvent(organizationScope(defaultOrganizationId), "organizationUpdated", {
type: "organizationUpdated",
snapshot: buildOrganizationSummary(),
} satisfies OrganizationEvent);
};
const emitTaskUpdate = (taskId: string): void => {
const task = requireTask(taskId);
emitConnectionEvent(taskScope(defaultOrganizationId, task.repoId, task.id), "taskUpdated", {
type: "taskDetailUpdated",
type: "taskUpdated",
detail: buildTaskDetail(task),
} satisfies TaskEvent);
};
@ -400,6 +396,10 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return unsupportedAppSnapshot();
},
async setAppDefaultModel(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async updateAppOrganizationProfile(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
@ -433,7 +433,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
},
async listRepos(_organizationId: string): Promise<RepoRecord[]> {
return workbench.getSnapshot().repos.map((repo) => ({
return workspace.getSnapshot().repos.map((repo) => ({
organizationId: defaultOrganizationId,
repoId: repo.id,
remoteUrl: mockRepoRemote(repo.label),
@ -447,7 +447,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
},
async listTasks(_organizationId: string, repoId?: string): Promise<TaskSummary[]> {
return workbench
return workspace
.getSnapshot()
.tasks.filter((task) => !repoId || task.repoId === repoId)
.map((task) => ({
@ -641,24 +641,24 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return buildOrganizationSummary();
},
async getTaskDetail(_organizationId: string, _repoId: string, taskId: string): Promise<WorkbenchTaskDetail> {
async getTaskDetail(_organizationId: string, _repoId: string, taskId: string): Promise<WorkspaceTaskDetail> {
return buildTaskDetail(requireTask(taskId));
},
async getSessionDetail(_organizationId: string, _repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail> {
async getSessionDetail(_organizationId: string, _repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return buildSessionDetail(requireTask(taskId), sessionId);
},
async getWorkbench(): Promise<TaskWorkbenchSnapshot> {
return workbench.getSnapshot();
async getWorkspace(): Promise<TaskWorkspaceSnapshot> {
return workspace.getSnapshot();
},
subscribeWorkbench(_organizationId: string, listener: () => void): () => void {
return workbench.subscribe(listener);
subscribeWorkspace(_organizationId: string, listener: () => void): () => void {
return workspace.subscribe(listener);
},
async createWorkbenchTask(_organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const created = await workbench.createTask(input);
async createWorkspaceTask(_organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
const created = await workspace.createTask(input);
emitOrganizationSnapshot();
emitTaskUpdate(created.taskId);
if (created.sessionId) {
@ -667,99 +667,93 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return created;
},
async markWorkbenchUnread(_organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await workbench.markTaskUnread(input);
async markWorkspaceUnread(_organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await workspace.markTaskUnread(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async renameWorkbenchTask(_organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await workbench.renameTask(input);
async renameWorkspaceTask(_organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await workspace.renameTask(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async renameWorkbenchBranch(_organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await workbench.renameBranch(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async createWorkbenchSession(_organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const created = await workbench.addSession(input);
async createWorkspaceSession(_organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const created = await workspace.addSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, created.sessionId);
return created;
},
async renameWorkbenchSession(_organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> {
await workbench.renameSession(input);
async renameWorkspaceSession(_organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
await workspace.renameSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async setWorkbenchSessionUnread(_organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await workbench.setSessionUnread(input);
async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await workspace.setSessionUnread(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async updateWorkbenchDraft(_organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await workbench.updateDraft(input);
async updateWorkspaceDraft(_organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await workspace.updateDraft(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async changeWorkbenchModel(_organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
await workbench.changeModel(input);
async changeWorkspaceModel(_organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await workspace.changeModel(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async sendWorkbenchMessage(_organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
await workbench.sendMessage(input);
async sendWorkspaceMessage(_organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await workspace.sendMessage(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async stopWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> {
await workbench.stopAgent(input);
async stopWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.stopAgent(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async closeWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> {
await workbench.closeSession(input);
async closeWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.closeSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async publishWorkbenchPr(_organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await workbench.publishPr(input);
async publishWorkspacePr(_organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await workspace.publishPr(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async revertWorkbenchFile(_organizationId: string, input: TaskWorkbenchDiffInput): Promise<void> {
await workbench.revertFile(input);
async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await workspace.revertFile(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async reloadGithubOrganization(): Promise<void> {},
async adminReloadGithubOrganization(): Promise<void> {},
async reloadGithubPullRequests(): Promise<void> {},
async adminReloadGithubPullRequests(): Promise<void> {},
async reloadGithubRepository(): Promise<void> {},
async adminReloadGithubRepository(): Promise<void> {},
async reloadGithubPullRequest(): Promise<void> {},
async adminReloadGithubPullRequest(): Promise<void> {},
async health(): Promise<{ ok: true }> {
return { ok: true };

View file

@ -1,33 +1,33 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchRepositories,
groupWorkspaceRepositories,
nowMs,
providerAgent,
randomReply,
removeFileTreePath,
slugify,
uid,
} from "../workbench-model.js";
} from "../workspace-model.js";
import type {
TaskWorkbenchAddSessionResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
WorkbenchSession as AgentSession,
WorkbenchTask as Task,
WorkbenchTranscriptEvent as TranscriptEvent,
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
WorkspaceSession as AgentSession,
WorkspaceTask as Task,
WorkspaceTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import type { TaskWorkbenchClient } from "../workbench-client.js";
import type { TaskWorkspaceClient } from "../workspace-client.js";
function buildTranscriptEvent(params: {
sessionId: string;
@ -47,12 +47,12 @@ function buildTranscriptEvent(params: {
};
}
class MockWorkbenchStore implements TaskWorkbenchClient {
class MockWorkspaceStore implements TaskWorkspaceClient {
private snapshot = buildInitialMockLayoutViewModel();
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): TaskWorkbenchSnapshot {
getSnapshot(): TaskWorkspaceSnapshot {
return this.snapshot;
}
@ -63,7 +63,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
async createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
const id = uid();
const sessionId = `session-${id}`;
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
@ -109,7 +109,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
return { taskId: id, sessionId };
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
async markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void> {
this.updateTask(input.taskId, (task) => {
const targetSession = task.sessions[task.sessions.length - 1] ?? null;
if (!targetSession) {
@ -123,7 +123,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
});
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
async renameTask(input: TaskWorkspaceRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename task ${input.taskId} to an empty title`);
@ -131,19 +131,11 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() }));
}
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`);
}
this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() }));
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
async archiveTask(input: TaskWorkspaceSelectInput): Promise<void> {
this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() }));
}
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
async publishPr(input: TaskWorkspaceSelectInput): Promise<void> {
const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1;
this.updateTask(input.taskId, (task) => ({
...task,
@ -152,7 +144,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}));
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
async revertFile(input: TaskWorkspaceDiffInput): Promise<void> {
this.updateTask(input.taskId, (task) => {
const file = task.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...task.diffs };
@ -167,7 +159,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
});
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId);
this.updateTask(input.taskId, (task) => ({
...task,
@ -187,7 +179,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}));
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
async sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void> {
const text = input.text.trim();
if (!text) {
throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`);
@ -288,7 +280,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
this.pendingTimers.set(input.sessionId, timer);
}
async stopAgent(input: TaskWorkbenchSessionInput): Promise<void> {
async stopAgent(input: TaskWorkspaceSessionInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId);
const existing = this.pendingTimers.get(input.sessionId);
if (existing) {
@ -311,14 +303,14 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
});
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
sessions: currentTask.sessions.map((candidate) => (candidate.id === input.sessionId ? { ...candidate, unread: input.unread } : candidate)),
}));
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
async renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void> {
const title = input.title.trim();
if (!title) {
throw new Error(`Cannot rename session ${input.sessionId} to an empty title`);
@ -329,7 +321,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}));
}
async closeSession(input: TaskWorkbenchSessionInput): Promise<void> {
async closeSession(input: TaskWorkspaceSessionInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => {
if (currentTask.sessions.length <= 1) {
return currentTask;
@ -342,7 +334,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
});
}
async addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse> {
async addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse> {
this.assertTask(input.taskId);
const nextSessionId = uid();
const nextSession: AgentSession = {
@ -368,7 +360,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
return { sessionId: nextSession.id };
}
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
async changeModel(input: TaskWorkspaceChangeModelInput): Promise<void> {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
if (!group) {
throw new Error(`Unable to resolve model provider for ${input.model}`);
@ -382,11 +374,11 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}));
}
private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void {
private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void {
const nextSnapshot = updater(this.snapshot);
this.snapshot = {
...nextSnapshot,
repositories: groupWorkbenchRepositories(nextSnapshot.repos, nextSnapshot.tasks),
repositories: groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks),
};
this.notify();
}
@ -436,11 +428,11 @@ function candidateEventIndex(task: Task, sessionId: string): number {
return (session?.transcript.length ?? 0) + 1;
}
let sharedMockWorkbenchClient: TaskWorkbenchClient | null = null;
let sharedMockWorkspaceClient: TaskWorkspaceClient | null = null;
export function getSharedMockWorkbenchClient(): TaskWorkbenchClient {
if (!sharedMockWorkbenchClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore();
export function getSharedMockWorkspaceClient(): TaskWorkspaceClient {
if (!sharedMockWorkspaceClient) {
sharedMockWorkspaceClient = new MockWorkspaceStore();
}
return sharedMockWorkbenchClient;
return sharedMockWorkspaceClient;
}

View file

@ -1,4 +1,4 @@
import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import type { FoundryAppClient } from "../app-client.js";
@ -72,6 +72,11 @@ class RemoteFoundryAppStore implements FoundryAppClient {
this.notify();
}
async setDefaultModel(model: WorkspaceModelId): Promise<void> {
this.snapshot = await this.backend.setAppDefaultModel(model);
this.notify();
}
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
this.snapshot = await this.backend.updateAppOrganizationProfile(input);
this.notify();

View file

@ -1,198 +0,0 @@
import type {
TaskWorkbenchAddSessionResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchRepositories } from "../workbench-model.js";
import type { TaskWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
organizationId: string;
}
class RemoteWorkbenchStore implements TaskWorkbenchClient {
private readonly backend: BackendClient;
private readonly organizationId: string;
private snapshot: TaskWorkbenchSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkbench: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkbenchClientOptions) {
this.backend = options.backend;
this.organizationId = options.organizationId;
this.snapshot = {
organizationId: options.organizationId,
repos: [],
repositories: [],
tasks: [],
};
}
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkbench) {
this.unsubscribeWorkbench();
this.unsubscribeWorkbench = null;
}
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const created = await this.backend.createWorkbenchTask(this.organizationId, input);
await this.refresh();
return created;
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.markWorkbenchUnread(this.organizationId, input);
await this.refresh();
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchTask(this.organizationId, input);
await this.refresh();
}
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchBranch(this.organizationId, input);
await this.refresh();
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.organizationId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.organizationId, input);
await this.refresh();
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.organizationId, input);
await this.refresh();
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.organizationId, input);
// Skip refresh — the server broadcast will trigger it, and the frontend
// holds local draft state to avoid the round-trip overwriting user input.
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.backend.sendWorkbenchMessage(this.organizationId, input);
await this.refresh();
}
async stopAgent(input: TaskWorkbenchSessionInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.organizationId, input);
await this.refresh();
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.organizationId, input);
await this.refresh();
}
async closeSession(input: TaskWorkbenchSessionInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.organizationId, input);
await this.refresh();
}
async addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse> {
const created = await this.backend.createWorkbenchSession(this.organizationId, input);
await this.refresh();
return created;
}
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
await this.backend.changeWorkbenchModel(this.organizationId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkbench) {
this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.organizationId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkbench(this.organizationId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
repositories: nextSnapshot.repositories ?? groupWorkbenchRepositories(nextSnapshot.repos, nextSnapshot.tasks),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): TaskWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -0,0 +1,193 @@
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkspaceRepositories } from "../workspace-model.js";
import type { TaskWorkspaceClient } from "../workspace-client.js";
export interface RemoteWorkspaceClientOptions {
backend: BackendClient;
organizationId: string;
}
class RemoteWorkspaceStore implements TaskWorkspaceClient {
private readonly backend: BackendClient;
private readonly organizationId: string;
private snapshot: TaskWorkspaceSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkspace: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkspaceClientOptions) {
this.backend = options.backend;
this.organizationId = options.organizationId;
this.snapshot = {
organizationId: options.organizationId,
repos: [],
repositories: [],
tasks: [],
};
}
getSnapshot(): TaskWorkspaceSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkspace) {
this.unsubscribeWorkspace();
this.unsubscribeWorkspace = null;
}
};
}
async createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
const created = await this.backend.createWorkspaceTask(this.organizationId, input);
await this.refresh();
return created;
}
async markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void> {
await this.backend.markWorkspaceUnread(this.organizationId, input);
await this.refresh();
}
async renameTask(input: TaskWorkspaceRenameInput): Promise<void> {
await this.backend.renameWorkspaceTask(this.organizationId, input);
await this.refresh();
}
async archiveTask(input: TaskWorkspaceSelectInput): Promise<void> {
await this.backend.runAction(this.organizationId, input.repoId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: TaskWorkspaceSelectInput): Promise<void> {
await this.backend.publishWorkspacePr(this.organizationId, input);
await this.refresh();
}
async revertFile(input: TaskWorkspaceDiffInput): Promise<void> {
await this.backend.revertWorkspaceFile(this.organizationId, input);
await this.refresh();
}
async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await this.backend.updateWorkspaceDraft(this.organizationId, input);
// Skip refresh — the server broadcast will trigger it, and the frontend
// holds local draft state to avoid the round-trip overwriting user input.
}
async sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void> {
await this.backend.sendWorkspaceMessage(this.organizationId, input);
await this.refresh();
}
async stopAgent(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.stopWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkspaceSessionUnread(this.organizationId, input);
await this.refresh();
}
async renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void> {
await this.backend.renameWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async closeSession(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.closeWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse> {
const created = await this.backend.createWorkspaceSession(this.organizationId, input);
await this.refresh();
return created;
}
async changeModel(input: TaskWorkspaceChangeModelInput): Promise<void> {
await this.backend.changeWorkspaceModel(this.organizationId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkspace) {
this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkspace(this.organizationId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
repositories: nextSnapshot.repositories ?? groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkspaceClient(options: RemoteWorkspaceClientOptions): TaskWorkspaceClient {
return new RemoteWorkspaceStore(options);
}

View file

@ -5,8 +5,8 @@ import type {
SandboxProcessesEvent,
SessionEvent,
TaskEvent,
WorkbenchSessionDetail,
WorkbenchTaskDetail,
WorkspaceSessionDetail,
WorkspaceTaskDetail,
OrganizationEvent,
OrganizationSummarySnapshot,
} from "@sandbox-agent/foundry-shared";
@ -48,16 +48,6 @@ export interface SandboxProcessesTopicParams {
sandboxId: string;
}
function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
const filtered = items.filter((item) => item.id !== nextItem.id);
return [...filtered, nextItem].sort(sort);
}
function upsertByPrId<T extends { prId: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
const filtered = items.filter((item) => item.prId !== nextItem.prId);
return [...filtered, nextItem].sort(sort);
}
export const topicDefinitions = {
app: {
key: () => "app",
@ -72,41 +62,7 @@ export const topicDefinitions = {
event: "organizationUpdated",
connect: (backend: BackendClient, params: OrganizationTopicParams) => backend.connectOrganization(params.organizationId),
fetchInitial: (backend: BackendClient, params: OrganizationTopicParams) => backend.getOrganizationSummary(params.organizationId),
applyEvent: (current: OrganizationSummarySnapshot, event: OrganizationEvent) => {
switch (event.type) {
case "taskSummaryUpdated":
return {
...current,
taskSummaries: upsertById(current.taskSummaries, event.taskSummary, (left, right) => right.updatedAtMs - left.updatedAtMs),
};
case "taskRemoved":
return {
...current,
taskSummaries: current.taskSummaries.filter((task) => task.id !== event.taskId),
};
case "repoAdded":
case "repoUpdated":
return {
...current,
repos: upsertById(current.repos, event.repo, (left, right) => right.latestActivityMs - left.latestActivityMs),
};
case "repoRemoved":
return {
...current,
repos: current.repos.filter((repo) => repo.id !== event.repoId),
};
case "pullRequestUpdated":
return {
...current,
openPullRequests: upsertByPrId(current.openPullRequests, event.pullRequest, (left, right) => right.updatedAtMs - left.updatedAtMs),
};
case "pullRequestRemoved":
return {
...current,
openPullRequests: current.openPullRequests.filter((pullRequest) => pullRequest.prId !== event.prId),
};
}
},
applyEvent: (_current: OrganizationSummarySnapshot, event: OrganizationEvent) => event.snapshot,
} satisfies TopicDefinition<OrganizationSummarySnapshot, OrganizationTopicParams, OrganizationEvent>,
task: {
@ -114,8 +70,8 @@ export const topicDefinitions = {
event: "taskUpdated",
connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
applyEvent: (_current: WorkbenchTaskDetail, event: TaskEvent) => event.detail,
} satisfies TopicDefinition<WorkbenchTaskDetail, TaskTopicParams, TaskEvent>,
applyEvent: (_current: WorkspaceTaskDetail, event: TaskEvent) => event.detail,
} satisfies TopicDefinition<WorkspaceTaskDetail, TaskTopicParams, TaskEvent>,
session: {
key: (params: SessionTopicParams) => `session:${params.organizationId}:${params.taskId}:${params.sessionId}`,
@ -123,13 +79,13 @@ export const topicDefinitions = {
connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: SessionTopicParams) =>
backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId),
applyEvent: (current: WorkbenchSessionDetail, event: SessionEvent) => {
applyEvent: (current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== current.sessionId) {
return current;
}
return event.session;
},
} satisfies TopicDefinition<WorkbenchSessionDetail, SessionTopicParams, SessionEvent>,
} satisfies TopicDefinition<WorkspaceSessionDetail, SessionTopicParams, SessionEvent>,
sandboxProcesses: {
key: (params: SandboxProcessesTopicParams) => `sandbox:${params.organizationId}:${params.sandboxProviderId}:${params.sandboxId}`,

View file

@ -1,64 +0,0 @@
import type {
TaskWorkbenchAddSessionResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type TaskWorkbenchClientMode = "mock" | "remote";
export interface CreateTaskWorkbenchClientOptions {
mode: TaskWorkbenchClientMode;
backend?: BackendClient;
organizationId?: string;
}
export interface TaskWorkbenchClient {
getSnapshot(): TaskWorkbenchSnapshot;
subscribe(listener: () => void): () => 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>;
revertFile(input: TaskWorkbenchDiffInput): Promise<void>;
updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkbenchSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkbenchSessionInput): Promise<void>;
addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse>;
changeModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
}
export function createTaskWorkbenchClient(options: CreateTaskWorkbenchClientOptions): TaskWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
}
if (!options.backend) {
throw new Error("Remote task workbench client requires a backend client");
}
if (!options.organizationId) {
throw new Error("Remote task workbench client requires a organization id");
}
return createRemoteWorkbenchClient({
backend: options.backend,
organizationId: options.organizationId,
});
}

View file

@ -0,0 +1,63 @@
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkspaceClient } from "./mock/workspace-client.js";
import { createRemoteWorkspaceClient } from "./remote/workspace-client.js";
export type TaskWorkspaceClientMode = "mock" | "remote";
export interface CreateTaskWorkspaceClientOptions {
mode: TaskWorkspaceClientMode;
backend?: BackendClient;
organizationId?: string;
}
export interface TaskWorkspaceClient {
getSnapshot(): TaskWorkspaceSnapshot;
subscribe(listener: () => void): () => void;
createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void>;
renameTask(input: TaskWorkspaceRenameInput): Promise<void>;
archiveTask(input: TaskWorkspaceSelectInput): Promise<void>;
publishPr(input: TaskWorkspaceSelectInput): Promise<void>;
revertFile(input: TaskWorkspaceDiffInput): Promise<void>;
updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkspaceSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;
addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse>;
changeModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
}
export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient {
if (options.mode === "mock") {
return getSharedMockWorkspaceClient();
}
if (!options.backend) {
throw new Error("Remote task workspace client requires a backend client");
}
if (!options.organizationId) {
throw new Error("Remote task workspace client requires a organization id");
}
return createRemoteWorkspaceClient({
backend: options.backend,
organizationId: options.organizationId,
});
}

View file

@ -1,17 +1,17 @@
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchSession as AgentSession,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchTask as Task,
TaskWorkbenchSnapshot,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchRepositorySection,
WorkbenchRepo,
WorkbenchTranscriptEvent as TranscriptEvent,
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
WorkspaceDiffLineKind as DiffLineKind,
WorkspaceFileTreeNode as FileTreeNode,
WorkspaceTask as Task,
TaskWorkspaceSnapshot,
WorkspaceHistoryEvent as HistoryEvent,
WorkspaceModelGroup as ModelGroup,
WorkspaceModelId as ModelId,
WorkspaceParsedDiffLine as ParsedDiffLine,
WorkspaceRepositorySection,
WorkspaceRepo,
WorkspaceTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -1300,7 +1300,7 @@ export function buildInitialTasks(): Task[] {
* Uses real public repos so the mock sidebar matches what an actual rivet-dev
* organization would show after a GitHub sync.
*/
function buildMockRepos(): WorkbenchRepo[] {
function buildMockRepos(): WorkspaceRepo[] {
return rivetDevFixture.repos.map((r) => ({
id: repoIdFromFullName(r.fullName),
label: r.fullName,
@ -1349,19 +1349,19 @@ function buildPrTasks(): Task[] {
});
}
export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot {
export function buildInitialMockLayoutViewModel(): TaskWorkspaceSnapshot {
const repos = buildMockRepos();
const tasks = [...buildInitialTasks(), ...buildPrTasks()];
return {
organizationId: "default",
repos,
repositories: groupWorkbenchRepositories(repos, tasks),
repositories: groupWorkspaceRepositories(repos, tasks),
tasks,
};
}
export function groupWorkbenchRepositories(repos: WorkbenchRepo[], tasks: Task[]): WorkbenchRepositorySection[] {
const grouped = new Map<string, WorkbenchRepositorySection>();
export function groupWorkspaceRepositories(repos: WorkspaceRepo[], tasks: Task[]): WorkspaceRepositorySection[] {
const grouped = new Map<string, WorkspaceRepositorySection>();
for (const repo of repos) {
grouped.set(repo.id, {

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared";
import type { AuditLogEvent as HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared";
import type { AuditLogEvent as HistoryEvent, TaskRecord } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { TaskWorkbenchSnapshot, WorkbenchSession, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared";
import type { TaskWorkspaceSnapshot, WorkspaceSession, WorkspaceTask, WorkspaceModelId, WorkspaceTranscriptEvent } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js";
@ -13,7 +13,7 @@ function requiredEnv(name: string): string {
return value;
}
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
@ -50,7 +50,7 @@ async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn:
}
}
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
@ -58,7 +58,7 @@ function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTas
return task;
}
function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession {
function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession {
const tab = task.sessions.find((candidate) => candidate.id === sessionId);
if (!tab) {
throw new Error(`tab ${sessionId} missing from task ${task.id}`);
@ -66,7 +66,7 @@ function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession {
return tab;
}
function extractEventText(event: WorkbenchTranscriptEvent): string {
function extractEventText(event: WorkspaceTranscriptEvent): string {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
@ -127,7 +127,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string {
return JSON.stringify(payload);
}
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
function transcriptIncludesAgentText(transcript: WorkspaceTranscriptEvent[], expectedText: string): boolean {
return transcript
.filter((event) => event.sender === "agent")
.map((event) => extractEventText(event))
@ -135,15 +135,15 @@ function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], exp
.includes(expectedText);
}
describe("e2e(client): workbench flows", () => {
describe("e2e(client): workspace flows", () => {
it.skipIf(!RUN_WORKBENCH_E2E)(
"creates a task from an imported repo, adds sessions, exchanges messages, and manages workbench state",
"creates a task from an imported repo, adds sessions, exchanges messages, and manages workspace state",
{ timeout: 20 * 60_000 },
async () => {
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-5.3-codex");
const model = workspaceModelEnv("HF_E2E_MODEL", "gpt-5.3-codex");
const runId = `wb-${Date.now().toString(36)}`;
const expectedFile = `${runId}.txt`;
const expectedInitialReply = `WORKBENCH_READY_${runId}`;
@ -155,9 +155,9 @@ describe("e2e(client): workbench flows", () => {
});
const repo = await requireImportedRepo(client, organizationId, repoRemote);
const created = await client.createWorkbenchTask(organizationId, {
const created = await client.createWorkspaceTask(organizationId, {
repoId: repo.repoId,
title: `Workbench E2E ${runId}`,
title: `Workspace E2E ${runId}`,
branch: `e2e/${runId}`,
model,
task: `Reply with exactly: ${expectedInitialReply}`,
@ -167,7 +167,7 @@ describe("e2e(client): workbench flows", () => {
"task provisioning",
12 * 60_000,
2_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => task.branch === `e2e/${runId}` && task.sessions.length > 0,
);
@ -177,7 +177,7 @@ describe("e2e(client): workbench flows", () => {
"initial agent response",
12 * 60_000,
2_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => {
const tab = findTab(task, primaryTab.id);
return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply);
@ -187,28 +187,28 @@ describe("e2e(client): workbench flows", () => {
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
await client.renameWorkbenchTask(organizationId, {
await client.renameWorkspaceTask(organizationId, {
taskId: created.taskId,
value: `Workbench E2E ${runId} Renamed`,
value: `Workspace E2E ${runId} Renamed`,
});
await client.renameWorkbenchSession(organizationId, {
await client.renameWorkspaceSession(organizationId, {
taskId: created.taskId,
sessionId: primaryTab.id,
title: "Primary Session",
});
const secondTab = await client.createWorkbenchSession(organizationId, {
const secondTab = await client.createWorkspaceSession(organizationId, {
taskId: created.taskId,
model,
});
await client.renameWorkbenchSession(organizationId, {
await client.renameWorkspaceSession(organizationId, {
taskId: created.taskId,
sessionId: secondTab.sessionId,
title: "Follow-up Session",
});
await client.updateWorkbenchDraft(organizationId, {
await client.updateWorkspaceDraft(organizationId, {
taskId: created.taskId,
sessionId: secondTab.sessionId,
text: [
@ -226,11 +226,11 @@ describe("e2e(client): workbench flows", () => {
],
});
const drafted = findTask(await client.getWorkbench(organizationId), created.taskId);
const drafted = findTask(await client.getWorkspace(organizationId), created.taskId);
expect(findTab(drafted, secondTab.sessionId).draft.text).toContain(expectedReply);
expect(findTab(drafted, secondTab.sessionId).draft.attachments).toHaveLength(1);
await client.sendWorkbenchMessage(organizationId, {
await client.sendWorkspaceMessage(organizationId, {
taskId: created.taskId,
sessionId: secondTab.sessionId,
text: [
@ -252,7 +252,7 @@ describe("e2e(client): workbench flows", () => {
"follow-up session response",
10 * 60_000,
2_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => {
const tab = findTab(task, secondTab.sessionId);
return (
@ -265,17 +265,17 @@ describe("e2e(client): workbench flows", () => {
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
expect(withSecondReply.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
await client.setWorkbenchSessionUnread(organizationId, {
await client.setWorkspaceSessionUnread(organizationId, {
taskId: created.taskId,
sessionId: secondTab.sessionId,
unread: false,
});
await client.markWorkbenchUnread(organizationId, { taskId: created.taskId });
await client.markWorkspaceUnread(organizationId, { taskId: created.taskId });
const unreadSnapshot = findTask(await client.getWorkbench(organizationId), created.taskId);
const unreadSnapshot = findTask(await client.getWorkspace(organizationId), created.taskId);
expect(unreadSnapshot.sessions.some((tab) => tab.unread)).toBe(true);
await client.closeWorkbenchSession(organizationId, {
await client.closeWorkspaceSession(organizationId, {
taskId: created.taskId,
sessionId: secondTab.sessionId,
});
@ -284,26 +284,26 @@ describe("e2e(client): workbench flows", () => {
"secondary session closed",
30_000,
1_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => !task.sessions.some((tab) => tab.id === secondTab.sessionId),
);
expect(closedSnapshot.sessions).toHaveLength(1);
await client.revertWorkbenchFile(organizationId, {
await client.revertWorkspaceFile(organizationId, {
taskId: created.taskId,
path: expectedFile,
});
const revertedSnapshot = await poll(
"file revert reflected in workbench",
"file revert reflected in workspace",
30_000,
1_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
);
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);
expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`);
expect(revertedSnapshot.title).toBe(`Workspace E2E ${runId} Renamed`);
expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session");
},
);

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
createFoundryLogger,
type TaskWorkbenchSnapshot,
type WorkbenchSession,
type WorkbenchTask,
type WorkbenchModelId,
type WorkbenchTranscriptEvent,
type TaskWorkspaceSnapshot,
type WorkspaceSession,
type WorkspaceTask,
type WorkspaceModelId,
type WorkspaceTranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js";
@ -14,7 +14,7 @@ const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E =
const logger = createFoundryLogger({
service: "foundry-client-e2e",
bindings: {
suite: "workbench-load",
suite: "workspace-load",
},
});
@ -26,7 +26,7 @@ function requiredEnv(name: string): string {
return value;
}
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
@ -72,7 +72,7 @@ async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn:
}
}
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
@ -80,7 +80,7 @@ function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTas
return task;
}
function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession {
function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession {
const tab = task.sessions.find((candidate) => candidate.id === sessionId);
if (!tab) {
throw new Error(`tab ${sessionId} missing from task ${task.id}`);
@ -88,7 +88,7 @@ function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession {
return tab;
}
function extractEventText(event: WorkbenchTranscriptEvent): string {
function extractEventText(event: WorkspaceTranscriptEvent): string {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
@ -138,7 +138,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string {
return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload);
}
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
function transcriptIncludesAgentText(transcript: WorkspaceTranscriptEvent[], expectedText: string): boolean {
return transcript
.filter((event) => event.sender === "agent")
.map((event) => extractEventText(event))
@ -150,7 +150,7 @@ function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
}
async function measureWorkbenchSnapshot(
async function measureWorkspaceSnapshot(
client: ReturnType<typeof createBackendClient>,
organizationId: string,
iterations: number,
@ -163,11 +163,11 @@ async function measureWorkbenchSnapshot(
transcriptEventCount: number;
}> {
const durations: number[] = [];
let snapshot: TaskWorkbenchSnapshot | null = null;
let snapshot: TaskWorkspaceSnapshot | null = null;
for (let index = 0; index < iterations; index += 1) {
const startedAt = performance.now();
snapshot = await client.getWorkbench(organizationId);
snapshot = await client.getWorkspace(organizationId);
durations.push(performance.now() - startedAt);
}
@ -191,12 +191,12 @@ async function measureWorkbenchSnapshot(
};
}
describe("e2e(client): workbench load", () => {
describe("e2e(client): workspace load", () => {
it.skipIf(!RUN_WORKBENCH_LOAD_E2E)("runs a simple sequential load profile against the real backend", { timeout: 30 * 60_000 }, async () => {
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-5.3-codex");
const model = workspaceModelEnv("HF_E2E_MODEL", "gpt-5.3-codex");
const taskCount = intEnv("HF_LOAD_TASK_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
@ -220,16 +220,16 @@ describe("e2e(client): workbench load", () => {
transcriptEventCount: number;
}> = [];
snapshotSeries.push(await measureWorkbenchSnapshot(client, organizationId, 2));
snapshotSeries.push(await measureWorkspaceSnapshot(client, organizationId, 2));
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.createWorkbenchTask(organizationId, {
const created = await client.createWorkspaceTask(organizationId, {
repoId: repo.repoId,
title: `Workbench Load ${runId}`,
title: `Workspace Load ${runId}`,
branch: `load/${runId}`,
model,
task: `Reply with exactly: ${initialReply}`,
@ -241,7 +241,7 @@ describe("e2e(client): workbench load", () => {
`task ${runId} provisioning`,
12 * 60_000,
pollIntervalMs,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => {
const tab = task.sessions[0];
return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply));
@ -256,13 +256,13 @@ describe("e2e(client): workbench load", () => {
for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) {
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
const createSessionStartedAt = performance.now();
const createdSession = await client.createWorkbenchSession(organizationId, {
const createdSession = await client.createWorkspaceSession(organizationId, {
taskId: created.taskId,
model,
});
createSessionLatencies.push(performance.now() - createSessionStartedAt);
await client.sendWorkbenchMessage(organizationId, {
await client.sendWorkspaceMessage(organizationId, {
taskId: created.taskId,
sessionId: createdSession.sessionId,
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
@ -274,7 +274,7 @@ describe("e2e(client): workbench load", () => {
`task ${runId} session ${sessionIndex} reply`,
10 * 60_000,
pollIntervalMs,
async () => findTask(await client.getWorkbench(organizationId), created.taskId),
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => {
const tab = findTab(task, createdSession.sessionId);
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
@ -285,14 +285,14 @@ describe("e2e(client): workbench load", () => {
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.sessionId).transcript, expectedReply)).toBe(true);
}
const snapshotMetrics = await measureWorkbenchSnapshot(client, organizationId, 3);
const snapshotMetrics = await measureWorkspaceSnapshot(client, organizationId, 3);
snapshotSeries.push(snapshotMetrics);
logger.info(
{
taskIndex: taskIndex + 1,
...snapshotMetrics,
},
"workbench_load_snapshot",
"workspace_load_snapshot",
);
}
@ -314,7 +314,7 @@ describe("e2e(client): workbench load", () => {
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
};
logger.info(summary, "workbench_load_summary");
logger.info(summary, "workspace_load_summary");
expect(createTaskLatencies.length).toBe(taskCount);
expect(provisionLatencies.length).toBe(taskCount);

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/keys.js";
import { auditLogKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/keys.js";
describe("actor keys", () => {
it("prefixes every key with organization namespace", () => {
@ -8,7 +8,7 @@ describe("actor keys", () => {
repositoryKey("default", "repo"),
taskKey("default", "repo", "task"),
taskSandboxKey("default", "sbx"),
historyKey("default", "repo"),
auditLogKey("default", "repo"),
];
for (const key of keys) {

View file

@ -115,17 +115,24 @@ describe("RemoteSubscriptionManager", () => {
]);
conn.emit("organizationUpdated", {
type: "taskSummaryUpdated",
taskSummary: {
id: "task-1",
repoId: "repo-1",
title: "Updated task",
status: "running",
repoName: "repo-1",
updatedAtMs: 20,
branch: "feature/live",
pullRequest: null,
sessionsSummary: [],
type: "organizationUpdated",
snapshot: {
organizationId: "org-1",
repos: [],
taskSummaries: [
{
id: "task-1",
repoId: "repo-1",
title: "Updated task",
status: "running",
repoName: "repo-1",
updatedAtMs: 20,
branch: "feature/live",
pullRequest: null,
sessionsSummary: [],
},
],
openPullRequests: [],
},
} satisfies OrganizationEvent);