Refactor Foundry GitHub and sandbox flows

This commit is contained in:
Nathan Flurry 2026-03-12 10:51:33 -07:00
parent 4bccd5fc8d
commit ec8e816d0d
112 changed files with 4026 additions and 2715 deletions

View file

@ -18,6 +18,7 @@ import type {
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
WorkbenchTask,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
@ -34,7 +35,7 @@ import type {
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import { createMockBackendClient } from "./mock/backend-client.js";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
import { sandboxInstanceKey, organizationKey, taskKey } from "./keys.js";
export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill";
@ -103,6 +104,10 @@ interface WorkspaceHandle {
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
}
interface TaskHandle {
getWorkbench(): Promise<WorkbenchTask>;
}
interface SandboxInstanceHandle {
createSession(input: {
prompt: string;
@ -124,9 +129,12 @@ interface SandboxInstanceHandle {
}
interface RivetClient {
workspace: {
organization: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle;
};
task: {
get(key?: string | string[]): TaskHandle;
};
sandboxInstance: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle;
};
@ -238,6 +246,7 @@ export interface BackendClient {
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot>;
getWorkbenchTask(workspaceId: string, taskId: string): Promise<WorkbenchTask>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchTask(workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
@ -482,7 +491,8 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const shouldUseCandidate = metadata.clientEndpoint ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) : true;
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
return createClient({
const buildClient = createClient as any;
return buildClient({
endpoint: resolvedEndpoint,
namespace: metadata.clientNamespace,
token: metadata.clientToken,
@ -495,7 +505,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
};
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
(await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), {
(await getClient()).organization.getOrCreate(organizationKey(workspaceId), {
createWithInput: workspaceId,
});
@ -504,6 +514,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
};
const taskById = async (workspaceId: string, taskId: string): Promise<TaskHandle> => {
const ws = await workspace(workspaceId);
const detail = await ws.getTask({ workspaceId, taskId });
const client = await getClient();
return client.task.get(taskKey(workspaceId, detail.repoId, taskId));
};
function isActorNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Actor not found");
@ -576,8 +593,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
entry.listeners.add(listener);
if (!entry.disposeConnPromise) {
entry.disposeConnPromise = (async () => {
const ensureConnection = (currentEntry: NonNullable<typeof entry>) => {
if (currentEntry.disposeConnPromise) {
return;
}
let reconnecting = false;
let disposeConnPromise: Promise<(() => Promise<void>) | null> | null = null;
disposeConnPromise = (async () => {
const handle = await workspace(workspaceId);
const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("workbenchUpdated", () => {
@ -589,14 +612,39 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
currentListener();
}
});
const unsubscribeError = conn.onError(() => {});
const unsubscribeError = conn.onError(() => {
if (reconnecting) {
return;
}
reconnecting = true;
const current = workbenchSubscriptions.get(workspaceId);
if (!current || current.disposeConnPromise !== disposeConnPromise) {
return;
}
current.disposeConnPromise = null;
void disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
if (current.listeners.size > 0) {
ensureConnection(current);
for (const currentListener of [...current.listeners]) {
currentListener();
}
}
});
return async () => {
unsubscribeEvent();
unsubscribeError();
await conn.dispose();
};
})().catch(() => null);
}
currentEntry.disposeConnPromise = disposeConnPromise;
};
ensureConnection(entry);
return () => {
const current = workbenchSubscriptions.get(workspaceId);
@ -984,6 +1032,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
},
async getWorkbenchTask(workspaceId: string, taskId: string): Promise<WorkbenchTask> {
return (await taskById(workspaceId, taskId)).getWorkbench();
},
subscribeWorkbench(workspaceId: string, listener: () => void): () => void {
return subscribeWorkbench(workspaceId, listener);
},

View file

@ -1,34 +1,30 @@
export type ActorKey = string[];
export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
export function organizationKey(organizationId: string): ActorKey {
return ["org", organizationId];
}
export function projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
export function repositoryKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repo", repoId];
}
export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "task", taskId];
export function githubStateKey(organizationId: string): ActorKey {
return ["org", organizationId, "github"];
}
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
export function taskKey(organizationId: string, repoId: string, taskId: string): ActorKey {
return ["org", organizationId, "repo", repoId, "task", taskId];
}
export function historyKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "history"];
export function sandboxInstanceKey(organizationId: string, providerId: string, sandboxId: string): ActorKey {
return ["org", organizationId, "provider", providerId, "sandbox", sandboxId];
}
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "pr-sync"];
export function historyKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repo", repoId, "history"];
}
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "branch-sync"];
}
export function taskStatusSyncKey(workspaceId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey {
export function taskStatusSyncKey(organizationId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
return ["org", organizationId, "repo", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
}

View file

@ -6,6 +6,8 @@ export type MockGithubInstallationStatus = "connected" | "install_required" | "r
export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
export type MockOrganizationKind = "personal" | "organization";
export type MockStarterRepoStatus = "pending" | "starred" | "skipped";
export type MockActorRuntimeStatus = "healthy" | "error";
export type MockActorRuntimeType = "organization" | "repository" | "task" | "history" | "sandbox_instance" | "task_status_sync";
export interface MockFoundryUser {
id: string;
@ -52,6 +54,27 @@ export interface MockFoundryGithubState {
lastSyncAt: number | null;
}
export interface MockFoundryActorRuntimeIssue {
actorId: string;
actorType: MockActorRuntimeType;
scopeId: string | null;
scopeLabel: string;
message: string;
workflowId: string | null;
stepName: string | null;
attempt: number | null;
willRetry: boolean;
retryDelayMs: number | null;
occurredAt: number;
}
export interface MockFoundryActorRuntimeState {
status: MockActorRuntimeStatus;
errorCount: number;
lastErrorAt: number | null;
issues: MockFoundryActorRuntimeIssue[];
}
export interface MockFoundryOrganizationSettings {
displayName: string;
slug: string;
@ -67,6 +90,7 @@ export interface MockFoundryOrganization {
kind: MockOrganizationKind;
settings: MockFoundryOrganizationSettings;
github: MockFoundryGithubState;
runtime: MockFoundryActorRuntimeState;
billing: MockFoundryBillingState;
members: MockFoundryOrganizationMember[];
seatAssignments: string[];
@ -140,6 +164,15 @@ function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus {
}
}
function buildHealthyRuntimeState(): MockFoundryActorRuntimeState {
return {
status: "healthy",
errorCount: 0,
lastErrorAt: null,
issues: [],
};
}
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
return {
auth: {
@ -203,6 +236,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
lastSyncLabel: "Synced just now",
lastSyncAt: Date.now() - 60_000,
},
runtime: buildHealthyRuntimeState(),
billing: {
planId: "free",
status: "active",
@ -237,6 +271,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
lastSyncLabel: "Waiting for first import",
lastSyncAt: null,
},
runtime: buildHealthyRuntimeState(),
billing: {
planId: "team",
status: "active",
@ -279,6 +314,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
lastSyncLabel: "Sync stalled 2 hours ago",
lastSyncAt: Date.now() - 2 * 60 * 60_000,
},
runtime: buildHealthyRuntimeState(),
billing: {
planId: "team",
status: "trialing",
@ -317,6 +353,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
lastSyncLabel: "Synced yesterday",
lastSyncAt: Date.now() - 24 * 60 * 60_000,
},
runtime: buildHealthyRuntimeState(),
billing: {
planId: "free",
status: "active",
@ -370,6 +407,7 @@ function parseStoredSnapshot(): MockFoundryAppSnapshot | null {
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
lastSyncAt: organization.github?.lastSyncAt ?? null,
},
runtime: organization.runtime ?? buildHealthyRuntimeState(),
})),
};
} catch {

View file

@ -82,6 +82,35 @@ function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskReco
return status;
}
function mapWorkbenchTaskStatus(task: TaskWorkbenchSnapshot["tasks"][number]): TaskRecord["status"] {
if (task.status === "archived") {
return "archived";
}
if (task.lifecycle?.state === "error") {
return "error";
}
if (task.status === "idle") {
return "idle";
}
if (task.status === "new") {
return task.lifecycle?.code ?? "init_create_sandbox";
}
return "running";
}
function mapWorkbenchTaskStatusMessage(task: TaskWorkbenchSnapshot["tasks"][number], status: TaskRecord["status"]): string {
if (status === "archived") {
return "archived";
}
if (status === "error") {
return task.lifecycle?.message ?? "mock task initialization failed";
}
if (task.status === "new") {
return task.lifecycle?.message ?? "mock sandbox provisioning";
}
return task.tabs.some((tab) => tab.status === "running") ? "agent responding" : "mock sandbox ready";
}
export function createMockBackendClient(defaultWorkspaceId = "default"): BackendClient {
const workbench = getSharedMockWorkbenchClient();
const listenersBySandboxId = new Map<string, Set<() => void>>();
@ -121,6 +150,8 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
const task = requireTask(taskId);
const cwd = mockCwd(task.repoName, task.id);
const archived = task.status === "archived";
const taskStatus = mapWorkbenchTaskStatus(task);
const sandboxAvailable = task.status !== "new" && taskStatus !== "error" && taskStatus !== "archived";
return {
workspaceId: defaultWorkspaceId,
repoId: task.repoId,
@ -130,21 +161,23 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
title: task.title,
task: task.title,
providerId: "local",
status: toTaskStatus(archived ? "archived" : "running", archived),
statusMessage: archived ? "archived" : "mock sandbox ready",
activeSandboxId: task.id,
activeSessionId: task.tabs[0]?.sessionId ?? null,
sandboxes: [
{
sandboxId: task.id,
providerId: "local",
sandboxActorId: "mock-sandbox",
switchTarget: `mock://${task.id}`,
cwd,
createdAt: task.updatedAtMs,
updatedAt: task.updatedAtMs,
},
],
status: toTaskStatus(taskStatus, archived),
statusMessage: mapWorkbenchTaskStatusMessage(task, taskStatus),
activeSandboxId: sandboxAvailable ? task.id : null,
activeSessionId: sandboxAvailable ? (task.tabs[0]?.sessionId ?? null) : null,
sandboxes: sandboxAvailable
? [
{
sandboxId: task.id,
providerId: "local",
sandboxActorId: "mock-sandbox",
switchTarget: `mock://${task.id}`,
cwd,
createdAt: task.updatedAtMs,
updatedAt: task.updatedAtMs,
},
]
: [],
agentType: task.tabs[0]?.agent === "Codex" ? "codex" : "claude",
prSubmitted: Boolean(task.pullRequest),
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
@ -272,7 +305,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
taskId: task.id,
branchName: task.branch,
title: task.title,
status: task.status === "archived" ? "archived" : "running",
status: mapWorkbenchTaskStatus(task),
updatedAt: task.updatedAtMs,
}));
},
@ -462,6 +495,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
return workbench.getSnapshot();
},
async getWorkbenchTask(_workspaceId: string, taskId: string) {
return requireTask(taskId);
},
subscribeWorkbench(_workspaceId: string, listener: () => void): () => void {
return workbench.subscribe(listener);
},

View file

@ -267,6 +267,40 @@ export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): F
export function buildInitialTasks(): Task[] {
return [
// ── rivet-dev/sandbox-agent ──
{
id: "h0",
repoId: "sandbox-agent",
title: "Recover from sandbox session bootstrap timeout",
status: "idle",
lifecycle: {
code: "error",
state: "error",
label: "Session startup failed",
message: "createSession failed after 3 attempts: upstream 504 Gateway Timeout",
},
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(1),
branch: "fix/session-bootstrap-timeout",
pullRequest: null,
tabs: [
{
id: "t0",
sessionId: null,
sessionName: "Failed startup",
agent: "Claude",
model: "claude-sonnet-4",
status: "error",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
},
],
fileChanges: [],
diffs: {},
fileTree: [],
},
{
id: "h1",
repoId: "sandbox-agent",