Complete Foundry refactor checklist

This commit is contained in:
Nathan Flurry 2026-03-15 13:38:51 -07:00 committed by Nathan Flurry
parent 40bed3b0a1
commit 13fc9cb318
91 changed files with 5091 additions and 4108 deletions

View file

@ -37,6 +37,7 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelGroup,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
@ -73,6 +74,10 @@ export interface ActorConn {
dispose(): Promise<void>;
}
interface AuthSessionScopedInput {
authSessionId?: string;
}
interface OrganizationHandle {
connect(): ActorConn;
listRepos(input: { organizationId: string }): Promise<RepoRecord[]>;
@ -91,24 +96,22 @@ interface OrganizationHandle {
useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getOrganizationSummary(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
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>;
createWorkspaceTask(input: TaskWorkspaceCreateTaskInput & AuthSessionScopedInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
renameWorkspaceTask(input: TaskWorkspaceRenameInput & AuthSessionScopedInput): Promise<void>;
createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string } & AuthSessionScopedInput): Promise<{ sessionId: string }>;
renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput & AuthSessionScopedInput): Promise<void>;
selectWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput & AuthSessionScopedInput): Promise<void>;
updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput & AuthSessionScopedInput): Promise<void>;
changeWorkspaceModel(input: TaskWorkspaceChangeModelInput & AuthSessionScopedInput): Promise<void>;
sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput & AuthSessionScopedInput): Promise<void>;
stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
adminReloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
}
interface AppOrganizationHandle {
@ -130,8 +133,8 @@ interface AppOrganizationHandle {
interface TaskHandle {
getTaskSummary(): Promise<WorkspaceTaskSummary>;
getTaskDetail(): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkspaceSessionDetail>;
getTaskDetail(input?: AuthSessionScopedInput): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string } & AuthSessionScopedInput): Promise<WorkspaceSessionDetail>;
connect(): ActorConn;
}
@ -156,6 +159,7 @@ interface TaskSandboxHandle {
rawSendSessionMethod(sessionId: string, method: string, params: Record<string, unknown>): Promise<unknown>;
destroySession(sessionId: string): Promise<void>;
sandboxAgentConnection(): Promise<{ endpoint: string; token?: string }>;
listWorkspaceModelGroups(): Promise<WorkspaceModelGroup[]>;
providerState(): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
}
@ -279,6 +283,7 @@ export interface BackendClient {
sandboxId: string,
): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getSandboxWorkspaceModelGroups(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<WorkspaceModelGroup[]>;
getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>;
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTaskDetail>;
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail>;
@ -289,6 +294,7 @@ export interface BackendClient {
renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void>;
selectWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void>;
@ -298,9 +304,7 @@ export interface BackendClient {
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>;
@ -460,6 +464,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
};
const getAuthSessionInput = async (): Promise<AuthSessionScopedInput | undefined> => {
const authSessionId = await getSessionId();
return authSessionId ? { authSessionId } : undefined;
};
const withAuthSessionInput = async <TInput extends object>(input: TInput): Promise<TInput & AuthSessionScopedInput> => {
const authSessionInput = await getAuthSessionInput();
return authSessionInput ? { ...input, ...authSessionInput } : input;
};
const organization = async (organizationId: string): Promise<OrganizationHandle> =>
client.organization.getOrCreate(organizationKey(organizationId), {
createWithInput: organizationId,
@ -492,17 +506,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
for (const row of candidates) {
try {
const detail = await ws.getTask({ organizationId, taskId: row.taskId });
const detail = await ws.getTask({ organizationId, repoId: row.repoId, taskId: row.taskId });
if (detail.sandboxProviderId !== sandboxProviderId) {
continue;
}
const sandbox = detail.sandboxes.find(
const sandboxes = detail.sandboxes as Array<(typeof detail.sandboxes)[number] & { sandboxActorId?: string }>;
const sandbox = sandboxes.find(
(sb) =>
sb.sandboxId === sandboxId &&
sb.sandboxProviderId === sandboxProviderId &&
typeof (sb as any).sandboxActorId === "string" &&
(sb as any).sandboxActorId.length > 0,
) as { sandboxActorId?: string } | undefined;
typeof sb.sandboxActorId === "string" &&
sb.sandboxActorId.length > 0,
);
if (sandbox?.sandboxActorId) {
return (client as any).taskSandbox.getForId(sandbox.sandboxActorId);
}
@ -562,14 +577,28 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
};
const getTaskDetailWithAuth = async (organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> => {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail(await getAuthSessionInput());
};
const getSessionDetailWithAuth = async (
organizationId: string,
repoId: string,
taskIdValue: string,
sessionId: string,
): Promise<WorkspaceSessionDetail> => {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail(await withAuthSessionInput({ sessionId }));
};
const getWorkspaceCompat = async (organizationId: string): Promise<TaskWorkspaceSnapshot> => {
const authSessionInput = await getAuthSessionInput();
const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId });
const tasks = (
await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
const resolvedTasks = await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
let detail;
try {
detail = await (await task(organizationId, taskSummary.repoId, taskSummary.id)).getTaskDetail();
const taskHandle = await task(organizationId, taskSummary.repoId, taskSummary.id);
detail = await taskHandle.getTaskDetail(authSessionInput);
} catch (error) {
if (isActorNotFoundError(error)) {
return null;
@ -579,7 +608,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const sessionDetails = await Promise.all(
detail.sessionsSummary.map(async (session) => {
try {
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({ sessionId: session.id });
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({
sessionId: session.id,
...(authSessionInput ?? {}),
});
return [session.id, full] as const;
} catch (error) {
if (isActorNotFoundError(error)) {
@ -599,6 +631,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
updatedAtMs: detail.updatedAtMs,
branch: detail.branch,
pullRequest: detail.pullRequest,
activeSessionId: detail.activeSessionId ?? null,
sessions: detail.sessionsSummary.map((session) => {
const full = sessionDetailsById.get(session.id);
return {
@ -619,10 +652,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
diffs: detail.diffs,
fileTree: detail.fileTree,
minutesUsed: detail.minutesUsed,
activeSandboxId: detail.activeSandboxId ?? null,
};
}),
)
).filter((task): task is TaskWorkspaceSnapshot["tasks"][number] => task !== null);
);
const tasks = resolvedTasks.filter((task): task is Exclude<(typeof resolvedTasks)[number], null> => task !== null);
const repositories = summary.repos
.map((repo) => ({
@ -1170,16 +1204,24 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await withSandboxHandle(organizationId, sandboxProviderId, sandboxId, async (handle) => handle.sandboxAgentConnection());
},
async getSandboxWorkspaceModelGroups(
organizationId: string,
sandboxProviderId: SandboxProviderId,
sandboxId: string,
): Promise<WorkspaceModelGroup[]> {
return await withSandboxHandle(organizationId, sandboxProviderId, sandboxId, async (handle) => handle.listWorkspaceModelGroups());
},
async getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot> {
return (await organization(organizationId)).getOrganizationSummary({ organizationId });
},
async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail();
return await getTaskDetailWithAuth(organizationId, repoId, taskIdValue);
},
async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId });
return await getSessionDetailWithAuth(organizationId, repoId, taskIdValue, sessionId);
},
async getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot> {
@ -1191,73 +1233,69 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
},
async createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
return (await organization(organizationId)).createWorkspaceTask(input);
return (await organization(organizationId)).createWorkspaceTask(await withAuthSessionInput(input));
},
async markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).markWorkspaceUnread(input);
await (await organization(organizationId)).markWorkspaceUnread(await withAuthSessionInput(input));
},
async renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceTask(input);
await (await organization(organizationId)).renameWorkspaceTask(await withAuthSessionInput(input));
},
async createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
return await (await organization(organizationId)).createWorkspaceSession(input);
return await (await organization(organizationId)).createWorkspaceSession(await withAuthSessionInput(input));
},
async renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceSession(input);
await (await organization(organizationId)).renameWorkspaceSession(await withAuthSessionInput(input));
},
async selectWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).selectWorkspaceSession(await withAuthSessionInput(input));
},
async setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await (await organization(organizationId)).setWorkspaceSessionUnread(input);
await (await organization(organizationId)).setWorkspaceSessionUnread(await withAuthSessionInput(input));
},
async updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await (await organization(organizationId)).updateWorkspaceDraft(input);
await (await organization(organizationId)).updateWorkspaceDraft(await withAuthSessionInput(input));
},
async changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await (await organization(organizationId)).changeWorkspaceModel(input);
await (await organization(organizationId)).changeWorkspaceModel(await withAuthSessionInput(input));
},
async sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await (await organization(organizationId)).sendWorkspaceMessage(input);
await (await organization(organizationId)).sendWorkspaceMessage(await withAuthSessionInput(input));
},
async stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).stopWorkspaceSession(input);
await (await organization(organizationId)).stopWorkspaceSession(await withAuthSessionInput(input));
},
async closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).closeWorkspaceSession(input);
await (await organization(organizationId)).closeWorkspaceSession(await withAuthSessionInput(input));
},
async publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).publishWorkspacePr(input);
await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input));
},
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await (await organization(organizationId)).revertWorkspaceFile(input);
await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input));
},
async adminReloadGithubOrganization(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubOrganization();
},
async adminReloadGithubPullRequests(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequests();
},
async adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubRepository({ repoId });
},
async adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequest({ repoId, prNumber });
},
async health(): Promise<{ ok: true }> {
const organizationId = options.defaultOrganizationId;
if (!organizationId) {

View file

@ -1,4 +1,8 @@
import type { WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, DEFAULT_WORKSPACE_MODEL_ID, type WorkspaceModelId } from "@sandbox-agent/foundry-shared";
const claudeModels = DEFAULT_WORKSPACE_MODEL_GROUPS.find((group) => group.agentKind === "Claude")?.models ?? [];
const CLAUDE_SECONDARY_MODEL_ID = claudeModels[1]?.id ?? claudeModels[0]?.id ?? DEFAULT_WORKSPACE_MODEL_ID;
const CLAUDE_TERTIARY_MODEL_ID = claudeModels[2]?.id ?? CLAUDE_SECONDARY_MODEL_ID;
import { injectMockLatency } from "./mock/latency.js";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -233,7 +237,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
defaultModel: "gpt-5.3-codex",
defaultModel: DEFAULT_WORKSPACE_MODEL_ID,
},
{
id: "user-maya",
@ -242,7 +246,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "maya",
roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"],
defaultModel: "claude-sonnet-4",
defaultModel: CLAUDE_SECONDARY_MODEL_ID,
},
{
id: "user-jamie",
@ -251,7 +255,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "jamie",
roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"],
defaultModel: "claude-opus-4",
defaultModel: CLAUDE_TERTIARY_MODEL_ID,
},
],
organizations: [

View file

@ -20,6 +20,7 @@ import type {
TaskWorkspaceUpdateDraftInput,
TaskEvent,
WorkspaceSessionDetail,
WorkspaceModelGroup,
WorkspaceTaskDetail,
WorkspaceTaskSummary,
OrganizationEvent,
@ -32,6 +33,7 @@ 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 { getSharedMockWorkspaceClient } from "./workspace-client.js";
@ -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,
@ -190,13 +193,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
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,
@ -236,6 +232,20 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
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 {
@ -298,9 +308,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
task: task.title,
sandboxProviderId: "local",
status: toTaskStatus(archived ? "archived" : "running", archived),
statusMessage: archived ? "archived" : "mock sandbox ready",
activeSandboxId: task.id,
activeSessionId: task.sessions[0]?.sessionId ?? null,
sandboxes: [
{
sandboxId: task.id,
@ -312,17 +320,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,
};
@ -636,6 +633,14 @@ 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();
},
@ -693,6 +698,13 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
emitSessionUpdate(input.taskId, input.sessionId);
},
async selectWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.selectSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await workspace.setSessionUnread(input);
emitOrganizationSnapshot();
@ -747,13 +759,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
},
async adminReloadGithubOrganization(): Promise<void> {},
async adminReloadGithubPullRequests(): Promise<void> {},
async adminReloadGithubRepository(): Promise<void> {},
async adminReloadGithubPullRequest(): Promise<void> {},
async health(): Promise<{ ok: true }> {
return { ok: true };
},

View file

@ -9,6 +9,7 @@ import {
slugify,
uid,
} from "../workspace-model.js";
import { DEFAULT_WORKSPACE_MODEL_ID, workspaceAgentForModel } from "@sandbox-agent/foundry-shared";
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
@ -74,20 +75,19 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
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,
@ -140,7 +140,18 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
pullRequest: {
number: nextPrNumber,
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(),
},
}));
}
@ -189,7 +200,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
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}`)];
@ -303,6 +314,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
});
}
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,
@ -329,6 +348,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
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),
};
});
@ -342,8 +362,8 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
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,
@ -355,6 +375,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
activeSessionId: nextSession.id,
sessions: [...currentTask.sessions, nextSession],
}));
return { sessionId: nextSession.id };
@ -369,7 +390,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
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,
),
}));
}

View file

@ -109,6 +109,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient {
await this.refresh();
}
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.selectWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkspaceSessionUnread(this.organizationId, input);
await this.refresh();

View file

@ -81,6 +81,7 @@ class TopicEntry<TData, TParams, TEvent> {
private unsubscribeError: (() => void) | null = null;
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
private startPromise: Promise<void> | null = null;
private eventPromise: Promise<void> = Promise.resolve();
private started = false;
constructor(
@ -157,12 +158,7 @@ class TopicEntry<TData, TParams, TEvent> {
try {
this.conn = await this.definition.connect(this.backend, this.params);
this.unsubscribeEvent = this.conn.on(this.definition.event, (event: TEvent) => {
if (this.data === undefined) {
return;
}
this.data = this.definition.applyEvent(this.data, event);
this.lastRefreshAt = Date.now();
this.notify();
void this.applyEvent(event);
});
this.unsubscribeError = this.conn.onError((error: unknown) => {
this.status = "error";
@ -182,6 +178,33 @@ class TopicEntry<TData, TParams, TEvent> {
}
}
private applyEvent(event: TEvent): Promise<void> {
this.eventPromise = this.eventPromise
.then(async () => {
if (!this.started || this.data === undefined) {
return;
}
const nextData = await this.definition.applyEvent(this.backend, this.params, this.data, event);
if (!this.started) {
return;
}
this.data = nextData;
this.status = "connected";
this.error = null;
this.lastRefreshAt = Date.now();
this.notify();
})
.catch((error) => {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
this.notify();
});
return this.eventPromise;
}
private notify(): void {
for (const listener of [...this.listeners]) {
listener();

View file

@ -16,15 +16,15 @@ import type { ActorConn, BackendClient, SandboxProcessRecord } from "../backend-
* Topic definitions for the subscription manager.
*
* Each topic describes one actor connection plus one materialized read model.
* Events always carry full replacement payloads for the changed entity so the
* client can replace cached state directly instead of reconstructing patches.
* Some topics can apply broadcast payloads directly, while others refetch
* through BackendClient so auth-scoped state stays user-specific.
*/
export interface TopicDefinition<TData, TParams, TEvent> {
key: (params: TParams) => string;
event: string;
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
applyEvent: (current: TData, event: TEvent) => TData;
applyEvent: (backend: BackendClient, params: TParams, current: TData, event: TEvent) => Promise<TData> | TData;
}
export interface AppTopicParams {}
@ -54,7 +54,7 @@ export const topicDefinitions = {
event: "appUpdated",
connect: (backend: BackendClient, _params: AppTopicParams) => backend.connectOrganization("app"),
fetchInitial: (backend: BackendClient, _params: AppTopicParams) => backend.getAppSnapshot(),
applyEvent: (_current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
applyEvent: (_backend: BackendClient, _params: AppTopicParams, _current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
organization: {
@ -62,7 +62,8 @@ 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) => event.snapshot,
applyEvent: (_backend: BackendClient, _params: OrganizationTopicParams, _current: OrganizationSummarySnapshot, event: OrganizationEvent) =>
event.snapshot,
} satisfies TopicDefinition<OrganizationSummarySnapshot, OrganizationTopicParams, OrganizationEvent>,
task: {
@ -70,7 +71,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: WorkspaceTaskDetail, event: TaskEvent) => event.detail,
applyEvent: (backend: BackendClient, params: TaskTopicParams, _current: WorkspaceTaskDetail, _event: TaskEvent) =>
backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
} satisfies TopicDefinition<WorkspaceTaskDetail, TaskTopicParams, TaskEvent>,
session: {
@ -79,11 +81,11 @@ 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: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== current.sessionId) {
applyEvent: async (backend: BackendClient, params: SessionTopicParams, current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== params.sessionId) {
return current;
}
return event.session;
return await backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId);
},
} satisfies TopicDefinition<WorkspaceSessionDetail, SessionTopicParams, SessionEvent>,
@ -94,7 +96,8 @@ export const topicDefinitions = {
backend.connectSandbox(params.organizationId, params.sandboxProviderId, params.sandboxId),
fetchInitial: async (backend: BackendClient, params: SandboxProcessesTopicParams) =>
(await backend.listSandboxProcesses(params.organizationId, params.sandboxProviderId, params.sandboxId)).processes,
applyEvent: (_current: SandboxProcessRecord[], event: SandboxProcessesEvent) => event.processes,
applyEvent: (_backend: BackendClient, _params: SandboxProcessesTopicParams, _current: SandboxProcessRecord[], event: SandboxProcessesEvent) =>
event.processes,
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
} as const;

View file

@ -65,7 +65,7 @@ export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
}
return rows.filter((row) => {
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task];
return fields.some((field) => fuzzyMatch(field, q));
});
}

View file

@ -37,6 +37,7 @@ export interface TaskWorkspaceClient {
updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkspaceSessionInput): Promise<void>;
selectSession(input: TaskWorkspaceSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;

View file

@ -1,3 +1,9 @@
import {
DEFAULT_WORKSPACE_MODEL_ID,
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
@ -15,26 +21,8 @@ import type {
} from "@sandbox-agent/foundry-shared";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
],
},
];
export const MODEL_GROUPS: ModelGroup[] = SharedModelGroups;
export const DEFAULT_MODEL_ID: ModelId = DEFAULT_WORKSPACE_MODEL_ID;
const MOCK_REPLIES = [
"Got it. I'll work on that now. Let me start by examining the relevant files...",
@ -73,15 +61,11 @@ export function formatMessageDuration(durationMs: number): string {
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
return sharedWorkspaceModelLabel(id, MODEL_GROUPS);
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
return sharedWorkspaceProviderAgent(provider);
}
export function slugify(text: string): string {
@ -204,6 +188,28 @@ export function buildHistoryEvents(sessions: AgentSession[]): HistoryEvent[] {
.sort((left, right) => messageOrder(left.messageId) - messageOrder(right.messageId));
}
function buildPullRequestSummary(params: {
number: number;
title: string;
branch: string;
repoName: string;
updatedAtMs: number;
status: "ready" | "draft";
}) {
return {
number: params.number,
title: params.title,
state: "open",
url: `https://github.com/${params.repoName}/pull/${params.number}`,
headRefName: params.branch,
baseRefName: "main",
repoFullName: params.repoName,
authorLogin: "mock",
isDraft: params.status === "draft",
updatedAtMs: params.updatedAtMs,
};
}
function transcriptFromLegacyMessages(sessionId: string, messages: LegacyMessage[]): TranscriptEvent[] {
return messages.map((message, index) => ({
id: message.id,
@ -315,14 +321,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(8),
branch: "NathanFlurry/pi-bootstrap-fix",
pullRequest: { number: 227, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 227,
title: "Normalize Pi ACP bootstrap payloads",
branch: "NathanFlurry/pi-bootstrap-fix",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(8),
status: "ready",
}),
sessions: [
{
id: "t1",
sessionId: "t1",
sessionName: "Pi payload fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -484,14 +497,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(3),
branch: "feat/builtin-agent-skills",
pullRequest: { number: 223, status: "draft" },
pullRequest: buildPullRequestSummary({
number: 223,
title: "Auto-inject builtin agent skills at startup",
branch: "feat/builtin-agent-skills",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(3),
status: "draft",
}),
sessions: [
{
id: "t3",
sessionId: "t3",
sessionName: "Skills injection",
agent: "Claude",
model: "claude-opus-4",
model: "opus",
status: "running",
thinkingSinceMs: NOW_MS - 45_000,
unread: false,
@ -584,14 +604,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(45),
branch: "hooks-example",
pullRequest: { number: 225, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 225,
title: "Add hooks example for Claude, Codex, and OpenCode",
branch: "hooks-example",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(45),
status: "ready",
}),
sessions: [
{
id: "t4",
sessionId: "t4",
sessionName: "Example docs",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -659,14 +686,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(15),
branch: "actor-reschedule-endpoint",
pullRequest: { number: 4400, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 4400,
title: "Add actor reschedule endpoint",
branch: "actor-reschedule-endpoint",
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(15),
status: "ready",
}),
sessions: [
{
id: "t5",
sessionId: "t5",
sessionName: "Reschedule API",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -793,14 +827,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(35),
branch: "feat/dynamic-actors",
pullRequest: { number: 4395, status: "draft" },
pullRequest: buildPullRequestSummary({
number: 4395,
title: "Dynamic actors",
branch: "feat/dynamic-actors",
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(35),
status: "draft",
}),
sessions: [
{
id: "t6",
sessionId: "t6",
sessionName: "Dynamic actors impl",
agent: "Claude",
model: "claude-opus-4",
model: "opus",
status: "idle",
thinkingSinceMs: null,
unread: true,
@ -850,14 +891,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/vbare",
updatedAtMs: minutesAgo(25),
branch: "fix-use-full-cloud-run-pool-name",
pullRequest: { number: 235, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 235,
title: "Use full cloud run pool name for routing",
branch: "fix-use-full-cloud-run-pool-name",
repoName: "rivet-dev/vbare",
updatedAtMs: minutesAgo(25),
status: "ready",
}),
sessions: [
{
id: "t7",
sessionId: "t7",
sessionName: "Pool routing fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -959,14 +1007,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(50),
branch: "fix-guard-support-https-targets",
pullRequest: { number: 125, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 125,
title: "Route compute gateway path correctly",
branch: "fix-guard-support-https-targets",
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(50),
status: "ready",
}),
sessions: [
{
id: "t8",
sessionId: "t8",
sessionName: "Guard routing",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -1073,14 +1128,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(2 * 24 * 60),
branch: "chore-move-compute-gateway-to",
pullRequest: { number: 123, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 123,
title: "Move compute gateway to guard",
branch: "chore-move-compute-gateway-to",
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(2 * 24 * 60),
status: "ready",
}),
sessions: [
{
id: "t9",
sessionId: "t9",
sessionName: "Gateway migration",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -1166,8 +1228,6 @@ export function buildInitialTasks(): Task[] {
repoId: "sandbox-agent",
title: "Fix broken auth middleware (error demo)",
status: "error",
runtimeStatus: "error",
statusMessage: "session:error",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(2),
branch: "fix/auth-middleware",
@ -1178,7 +1238,7 @@ export function buildInitialTasks(): Task[] {
sessionId: "status-error-session",
sessionName: "Auth fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "error",
thinkingSinceMs: null,
unread: false,
@ -1197,9 +1257,7 @@ export function buildInitialTasks(): Task[] {
id: "status-provisioning",
repoId: "sandbox-agent",
title: "Add rate limiting to API gateway (provisioning demo)",
status: "new",
runtimeStatus: "init_enqueue_provision",
statusMessage: "Queueing sandbox provisioning.",
status: "init_enqueue_provision",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(0),
branch: null,
@ -1211,7 +1269,7 @@ export function buildInitialTasks(): Task[] {
sandboxSessionId: null,
sessionName: "Session 1",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "pending_provision",
thinkingSinceMs: null,
unread: false,
@ -1259,7 +1317,6 @@ export function buildInitialTasks(): Task[] {
repoId: "sandbox-agent",
title: "Refactor WebSocket handler (running demo)",
status: "running",
runtimeStatus: "running",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(1),
branch: "refactor/ws-handler",
@ -1313,45 +1370,9 @@ function repoIdFromFullName(fullName: string): string {
return parts[parts.length - 1] ?? fullName;
}
/**
* Build task entries from open PR fixture data.
* Maps to the backend's PR sync behavior (RepositoryPrSyncActor) where PRs
* appear as first-class sidebar items even without an associated task.
* Each open PR gets a lightweight task entry so it shows in the sidebar.
*/
function buildPrTasks(): Task[] {
// Collect branch names already claimed by hand-written tasks so we don't duplicate
const existingBranches = new Set(
buildInitialTasks()
.map((t) => t.branch)
.filter(Boolean),
);
return rivetDevFixture.openPullRequests
.filter((pr) => !existingBranches.has(pr.headRefName))
.map((pr) => {
const repoId = repoIdFromFullName(pr.repoFullName);
return {
id: `pr-${repoId}-${pr.number}`,
repoId,
title: pr.title,
status: "idle" as const,
repoName: pr.repoFullName,
updatedAtMs: new Date(pr.updatedAt).getTime(),
branch: pr.headRefName,
pullRequest: { number: pr.number, status: pr.draft ? ("draft" as const) : ("ready" as const) },
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
};
});
}
export function buildInitialMockLayoutViewModel(): TaskWorkspaceSnapshot {
const repos = buildMockRepos();
const tasks = [...buildInitialTasks(), ...buildPrTasks()];
const tasks = buildInitialTasks();
return {
organizationId: "default",
repos,

View file

@ -80,9 +80,10 @@ function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
}
}
async function debugDump(client: ReturnType<typeof createBackendClient>, organizationId: string, taskId: string): Promise<string> {
async function debugDump(client: ReturnType<typeof createBackendClient>, organizationId: string, repoId: string, taskId: string): Promise<string> {
try {
const task = await client.getTask(organizationId, taskId);
const task = await client.getTask(organizationId, repoId, taskId);
const detail = await client.getTaskDetail(organizationId, repoId, taskId).catch(() => null);
const history = await client.listHistory({ organizationId, taskId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
@ -90,10 +91,11 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, organiz
.join("\n");
let sessionEventsSummary = "";
if (task.activeSandboxId && task.activeSessionId) {
const activeSessionId = detail?.activeSessionId ?? null;
if (task.activeSandboxId && activeSessionId) {
const events = await client
.listSandboxSessionEvents(organizationId, task.sandboxProviderId, task.activeSandboxId, {
sessionId: task.activeSessionId,
sessionId: activeSessionId,
limit: 50,
})
.then((r) => r.items)
@ -109,13 +111,11 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, organiz
JSON.stringify(
{
status: task.status,
statusMessage: task.statusMessage,
title: task.title,
branchName: task.branchName,
activeSandboxId: task.activeSandboxId,
activeSessionId: task.activeSessionId,
prUrl: task.prUrl,
prSubmitted: task.prSubmitted,
activeSessionId,
pullRequestUrl: detail?.pullRequest?.url ?? null,
},
null,
2,
@ -189,7 +189,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
// Cold local sandbox startup can exceed a few minutes on first run.
8 * 60_000,
1_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTask(organizationId, repo.repoId, created.taskId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
@ -200,18 +200,18 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<TaskRecord>(
const withSession = await poll<Awaited<ReturnType<typeof client.getTaskDetail>>>(
"task to create active session",
3 * 60_000,
1_500,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTaskDetail(organizationId, repo.repoId, created.taskId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
@ -219,7 +219,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -231,14 +231,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
2_000,
async () =>
(
await client.listSandboxSessionEvents(organizationId, withSession.sandboxProviderId, sandboxId!, {
await client.listSandboxSessionEvents(organizationId, namedAndProvisioned.sandboxProviderId, sandboxId!, {
sessionId: sessionId!,
limit: 40,
})
).items,
(events) => events.length > 0,
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -246,7 +246,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
"task to reach idle state",
8 * 60_000,
2_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTask(organizationId, repo.repoId, created.taskId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
@ -254,7 +254,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -266,7 +266,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
(events) => events.some((e) => e.kind === "task.pr_created"),
)
.catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
@ -287,16 +287,16 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
// Close the task and assert the sandbox is released (stopped).
await client.runAction(organizationId, created.taskId, "archive");
await client.runAction(organizationId, repo.repoId, created.taskId, "archive");
await poll<TaskRecord>(
await poll<Awaited<ReturnType<typeof client.getTaskDetail>>>(
"task to become archived (session released)",
60_000,
1_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTaskDetail(organizationId, repo.repoId, created.taskId),
(h) => h.status === "archived" && h.activeSessionId === null,
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -311,7 +311,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
return st.includes("destroyed") || st.includes("stopped") || st.includes("suspended") || st.includes("paused");
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
const state = await client.sandboxProviderState(organizationId, "local", sandboxId!).catch(() => null);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n` + `sandbox state: ${state ? state.state : "unknown"}\n` + `${dump}`);
});

View file

@ -15,19 +15,7 @@ function requiredEnv(name: string): string {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-5.3-codex":
case "gpt-5.4":
case "gpt-5.2-codex":
case "gpt-5.1-codex-max":
case "gpt-5.2":
case "gpt-5.1-codex-mini":
return value;
default:
return fallback;
}
return value && value.length > 0 ? value : fallback;
}
async function sleep(ms: number): Promise<void> {

View file

@ -28,19 +28,7 @@ function requiredEnv(name: string): string {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-5.3-codex":
case "gpt-5.4":
case "gpt-5.2-codex":
case "gpt-5.1-codex-max":
case "gpt-5.2":
case "gpt-5.1-codex-mini":
return value;
default:
return fallback;
}
return value && value.length > 0 ? value : fallback;
}
function intEnv(name: string, fallback: number): number {

View file

@ -50,6 +50,20 @@ class FakeActorConn implements ActorConn {
function organizationSnapshot(): OrganizationSummarySnapshot {
return {
organizationId: "org-1",
github: {
connectedAccount: "octocat",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
lastSyncAt: 10,
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 1,
syncPhase: null,
processedRepositoryCount: 1,
totalRepositoryCount: 1,
},
repos: [{ id: "repo-1", label: "repo-1", taskCount: 1, latestActivityMs: 10 }],
taskSummaries: [
{
@ -61,10 +75,10 @@ function organizationSnapshot(): OrganizationSummarySnapshot {
updatedAtMs: 10,
branch: "main",
pullRequest: null,
activeSessionId: null,
sessionsSummary: [],
},
],
openPullRequests: [],
};
}
@ -118,6 +132,20 @@ describe("RemoteSubscriptionManager", () => {
type: "organizationUpdated",
snapshot: {
organizationId: "org-1",
github: {
connectedAccount: "octocat",
installationStatus: "connected",
syncStatus: "syncing",
importedRepoCount: 1,
lastSyncLabel: "Syncing repositories...",
lastSyncAt: 10,
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 2,
syncPhase: "syncing_branches",
processedRepositoryCount: 1,
totalRepositoryCount: 3,
},
repos: [],
taskSummaries: [
{
@ -129,10 +157,10 @@ describe("RemoteSubscriptionManager", () => {
updatedAtMs: 20,
branch: "feature/live",
pullRequest: null,
activeSessionId: null,
sessionsSummary: [],
},
],
openPullRequests: [],
},
} satisfies OrganizationEvent);

View file

@ -12,9 +12,8 @@ const sample: TaskRecord = {
task: "Do test",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
@ -26,17 +25,6 @@ const sample: TaskRecord = {
updatedAt: 1,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1,
};