chore(foundry): migrate to actions (#262)

* feat(foundry): checkpoint actor and workspace refactor

* docs(foundry): add agent handoff context

* wip(foundry): continue actor refactor

* wip(foundry): capture remaining local changes

* Complete Foundry refactor checklist

* Fix Foundry validation fallout

* wip

* wip: convert all actors from workflow to plain run handlers

Workaround for RivetKit bug where c.queue.iter() never yields messages
for actors created via getOrCreate from another actor's context. The
queue accepts messages (visible in inspector) but the iterator hangs.
Sleep/wake fixes it, but actors with active connections never sleep.

Converted organization, github-data, task, and user actors from
run: workflow(...) to plain run: async (c) => { for await ... }.

Also fixes:
- Missing auth tables in org migration (auth_verification etc)
- default_model NOT NULL constraint on org profile upsert
- Nested workflow step in github-data (HistoryDivergedError)
- Removed --force from frontend Dockerfile pnpm install

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Convert all actors from queues/workflows to direct actions, lazy task creation

Major refactor replacing all queue-based workflow communication with direct
RivetKit action calls across all actors. This works around a RivetKit bug
where c.queue.iter() deadlocks for actors created from another actor's context.

Key changes:
- All actors (organization, task, user, audit-log, github-data) converted
  from run: workflow(...) to actions-only (no run handler, no queues)
- PR sync creates virtual task entries in org local DB instead of spawning
  task actors — prevents OOM from 200+ actors created simultaneously
- Task actors created lazily on first user interaction via getOrCreate,
  self-initialize from org's getTaskIndexEntry data
- Removed requireRepoExists cross-actor call (caused 500s), replaced with
  local resolveTaskRepoId from org's taskIndex table
- Fixed getOrganizationContext to thread overrides through all sync phases
- Fixed sandbox repo path (/home/user/repo for E2B compatibility)
- Fixed buildSessionDetail to skip transcript fetch for pending sessions
- Added process crash protection (uncaughtException/unhandledRejection)
- Fixed React infinite render loop in mock-layout useEffect dependencies
- Added sandbox listProcesses error handling for expired E2B sandboxes
- Set E2B sandbox timeout to 1 hour (was 5 min default)
- Updated CLAUDE.md with lazy task creation rules, no-silent-catch policy,
  React hook dependency safety rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix E2B sandbox timeout comment, frontend stability, and create-flow improvements

- Add TEMPORARY comment on E2B timeoutMs with pointer to rivetkit sandbox
  resilience proposal for when autoPause lands
- Fix React useEffect dependency stability in mock-layout and
  organization-dashboard to prevent infinite re-render loops
- Fix terminal-pane ref handling
- Improve create-flow service and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 15:23:59 -07:00 committed by GitHub
parent 32f3c6c3bc
commit f45a467484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 9768 additions and 7204 deletions

View file

@ -6,25 +6,26 @@ 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,
WorkspaceModelGroup,
WorkspaceTaskDetail,
WorkspaceTaskSummary,
OrganizationEvent,
OrganizationSummarySnapshot,
HistoryEvent,
AuditLogEvent as HistoryEvent,
HistoryQueryInput,
SandboxProviderId,
RepoOverview,
@ -32,9 +33,10 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
} from "@sandbox-agent/foundry-shared";
import { DEFAULT_WORKSPACE_MODEL_GROUPS } 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 +91,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 +99,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 +166,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,
@ -173,6 +175,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
updatedAtMs: task.updatedAtMs,
branch: task.branch,
pullRequest: task.pullRequest,
activeSessionId: task.activeSessionId ?? task.sessions[0]?.id ?? null,
sessionsSummary: task.sessions.map((tab) => ({
id: tab.id,
sessionId: tab.sessionId,
@ -187,16 +190,9 @@ 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",
runtimeStatus: toTaskStatus(task.status === "archived" ? "archived" : "running", task.status === "archived"),
statusMessage: task.status === "archived" ? "archived" : "mock sandbox ready",
activeSessionId: task.sessions[0]?.sessionId ?? null,
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
reviewStatus: null,
fileChanges: task.fileChanges,
diffs: task.diffs,
fileTree: task.fileTree,
@ -211,7 +207,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,10 +228,24 @@ 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,
github: {
connectedAccount: "mock",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: snapshot.repos.length,
lastSyncLabel: "Synced just now",
lastSyncAt: nowMs(),
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 1,
syncPhase: null,
processedRepositoryCount: snapshot.repos.length,
totalRepositoryCount: snapshot.repos.length,
},
repos: snapshot.repos.map((repo) => {
const repoTasks = taskSummaries.filter((task) => task.repoId === repo.id);
return {
@ -246,7 +256,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
};
}),
taskSummaries,
openPullRequests: [],
};
};
@ -256,20 +265,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);
};
@ -303,9 +308,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
task: task.title,
sandboxProviderId: "local",
status: toTaskStatus(archived ? "archived" : "running", archived),
statusMessage: archived ? "archived" : "mock sandbox ready",
pullRequest: null,
activeSandboxId: task.id,
activeSessionId: task.sessions[0]?.sessionId ?? null,
sandboxes: [
{
sandboxId: task.id,
@ -317,17 +321,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
updatedAt: task.updatedAtMs,
},
],
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
prSubmitted: Boolean(task.pullRequest),
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
prAuthor: task.pullRequest ? "mock" : null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: "0",
hasUnpushed: task.fileChanges.length > 0 ? "1" : "0",
parentBranch: null,
createdAt: task.updatedAtMs,
updatedAt: task.updatedAtMs,
};
@ -400,6 +393,10 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return unsupportedAppSnapshot();
},
async setAppDefaultModel(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async updateAppOrganizationProfile(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
@ -433,7 +430,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 +444,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) => ({
@ -457,6 +454,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
branchName: task.branch,
title: task.title,
status: task.status === "archived" ? "archived" : "running",
pullRequest: null,
updatedAt: task.updatedAtMs,
}));
},
@ -464,7 +462,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
async getRepoOverview(_organizationId: string, _repoId: string): Promise<RepoOverview> {
notSupported("getRepoOverview");
},
async getTask(_organizationId: string, taskId: string): Promise<TaskRecord> {
async getTask(_organizationId: string, _repoId: string, taskId: string): Promise<TaskRecord> {
return buildTaskRecord(taskId);
},
@ -472,7 +470,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return [];
},
async switchTask(_organizationId: string, taskId: string): Promise<SwitchResult> {
async switchTask(_organizationId: string, _repoId: string, taskId: string): Promise<SwitchResult> {
return {
organizationId: defaultOrganizationId,
taskId,
@ -481,14 +479,14 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
};
},
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 {
target: `mock://${taskId}`,
sessionId: requireTask(taskId).sessions[0]?.sessionId ?? null,
};
},
async runAction(_organizationId: string, _taskId: string): Promise<void> {
async runAction(_organizationId: string, _repoId: string, _taskId: string): Promise<void> {
notSupported("runAction");
},
@ -637,28 +635,32 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return { endpoint: "mock://terminal-unavailable" };
},
async getSandboxWorkspaceModelGroups(_organizationId: string, _sandboxProviderId: SandboxProviderId, _sandboxId: string): Promise<WorkspaceModelGroup[]> {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
},
async getOrganizationSummary(): Promise<OrganizationSummarySnapshot> {
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 +669,95 @@ 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 selectWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.selectSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async updateWorkbenchDraft(_organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await workbench.updateDraft(input);
async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await workspace.setSessionUnread(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async changeWorkbenchModel(_organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
await workbench.changeModel(input);
async updateWorkspaceDraft(_organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await workspace.updateDraft(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async sendWorkbenchMessage(_organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
await workbench.sendMessage(input);
async changeWorkspaceModel(_organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await workspace.changeModel(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async stopWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> {
await workbench.stopAgent(input);
async sendWorkspaceMessage(_organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await workspace.sendMessage(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async closeWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> {
await workbench.closeSession(input);
async stopWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.stopAgent(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
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 reloadGithubPullRequests(): Promise<void> {},
async reloadGithubRepository(): Promise<void> {},
async reloadGithubPullRequest(): Promise<void> {},
async adminReloadGithubOrganization(): Promise<void> {},
async adminReloadGithubRepository(): Promise<void> {},
async health(): Promise<{ ok: true }> {
return { ok: true };

View file

@ -1,33 +1,34 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchRepositories,
groupWorkspaceRepositories,
nowMs,
providerAgent,
randomReply,
removeFileTreePath,
slugify,
uid,
} from "../workbench-model.js";
} from "../workspace-model.js";
import { DEFAULT_WORKSPACE_MODEL_ID, workspaceAgentForModel } from "@sandbox-agent/foundry-shared";
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 +48,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 +64,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);
@ -74,20 +75,19 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
id,
repoId: repo.id,
title: input.title?.trim() || "New Task",
status: "new",
status: "init_enqueue_provision",
repoName: repo.label,
updatedAtMs: nowMs(),
branch: input.branch?.trim() || null,
pullRequest: null,
activeSessionId: sessionId,
sessions: [
{
id: sessionId,
sessionId: sessionId,
sessionName: "Session 1",
agent: providerAgent(
MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude",
),
model: input.model ?? "claude-sonnet-4",
agent: workspaceAgentForModel(input.model ?? DEFAULT_WORKSPACE_MODEL_ID, MODEL_GROUPS),
model: input.model ?? DEFAULT_WORKSPACE_MODEL_ID,
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -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,28 +131,32 @@ 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,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
pullRequest: {
number: nextPrNumber,
status: "ready",
title: task.title,
state: "open",
url: `https://example.test/pr/${nextPrNumber}`,
headRefName: task.branch ?? `task/${task.id}`,
baseRefName: "main",
repoFullName: task.repoName,
authorLogin: "mock",
isDraft: false,
updatedAtMs: nowMs(),
},
}));
}
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 +171,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 +191,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}`);
@ -197,7 +201,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
const startedAtMs = nowMs();
this.updateTask(input.taskId, (currentTask) => {
const isFirstOnTask = currentTask.status === "new";
const isFirstOnTask = String(currentTask.status).startsWith("init_");
const newTitle = isFirstOnTask ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentTask.title;
const newBranch = isFirstOnTask ? `feat/${slugify(newTitle)}` : currentTask.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
@ -288,7 +292,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 +315,22 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
});
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId);
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
activeSessionId: input.sessionId,
}));
}
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 +341,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;
@ -337,12 +349,13 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
return {
...currentTask,
activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId,
sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId),
};
});
}
async addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse> {
async addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse> {
this.assertTask(input.taskId);
const nextSessionId = uid();
const nextSession: AgentSession = {
@ -350,8 +363,8 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
sessionId: nextSessionId,
sandboxSessionId: null,
sessionName: `Session ${this.requireTask(input.taskId).sessions.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
agent: workspaceAgentForModel(DEFAULT_WORKSPACE_MODEL_ID, MODEL_GROUPS),
model: DEFAULT_WORKSPACE_MODEL_ID,
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -363,12 +376,13 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
activeSessionId: nextSession.id,
sessions: [...currentTask.sessions, nextSession],
}));
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}`);
@ -377,16 +391,16 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
sessions: currentTask.sessions.map((candidate) =>
candidate.id === input.sessionId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
candidate.id === input.sessionId ? { ...candidate, model: input.model, agent: workspaceAgentForModel(input.model, MODEL_GROUPS) } : candidate,
),
}));
}
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 +450,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;
}