Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View file

@ -0,0 +1,67 @@
import type {
FoundryAppSnapshot,
FoundryBillingPlanId,
FoundryOrganization,
FoundryUser,
UpdateFoundryOrganizationProfileInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getMockFoundryAppClient } from "./mock-app.js";
import { createRemoteFoundryAppClient } from "./remote/app-client.js";
export interface FoundryAppClient {
getSnapshot(): FoundryAppSnapshot;
subscribe(listener: () => void): () => void;
signInWithGithub(userId?: string): Promise<void>;
signOut(): Promise<void>;
skipStarterRepo(): Promise<void>;
starStarterRepo(organizationId: string): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
recordSeatUsage(workspaceId: string): Promise<void>;
}
export interface CreateFoundryAppClientOptions {
mode: "mock" | "remote";
backend?: BackendClient;
}
export function createFoundryAppClient(options: CreateFoundryAppClientOptions): FoundryAppClient {
if (options.mode === "mock") {
return getMockFoundryAppClient() as unknown as FoundryAppClient;
}
if (!options.backend) {
throw new Error("Remote app client requires a backend client");
}
return createRemoteFoundryAppClient({ backend: options.backend });
}
export function currentFoundryUser(snapshot: FoundryAppSnapshot): FoundryUser | null {
if (!snapshot.auth.currentUserId) {
return null;
}
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
}
export function currentFoundryOrganization(snapshot: FoundryAppSnapshot): FoundryOrganization | null {
if (!snapshot.activeOrganizationId) {
return null;
}
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
}
export function eligibleFoundryOrganizations(snapshot: FoundryAppSnapshot): FoundryOrganization[] {
const user = currentFoundryUser(snapshot);
if (!user) {
return [];
}
const eligible = new Set(user.eligibleOrganizationIds);
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
export * from "./app-client.js";
export * from "./backend-client.js";
export * from "./keys.js";
export * from "./mock-app.js";
export * from "./view-model.js";
export * from "./workbench-client.js";

View file

@ -0,0 +1,34 @@
export type ActorKey = string[];
export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
}
export function projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
}
export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "task", taskId];
}
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
}
export function historyKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "history"];
}
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "pr-sync"];
}
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 {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
}

View file

@ -0,0 +1,697 @@
import { injectMockLatency } from "./mock/latency.js";
export type MockBillingPlanId = "free" | "team";
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
export type MockOrganizationKind = "personal" | "organization";
export type MockStarterRepoStatus = "pending" | "starred" | "skipped";
export interface MockFoundryUser {
id: string;
name: string;
email: string;
githubLogin: string;
roleLabel: string;
eligibleOrganizationIds: string[];
}
export interface MockFoundryOrganizationMember {
id: string;
name: string;
email: string;
role: "owner" | "admin" | "member";
state: "active" | "invited";
}
export interface MockFoundryInvoice {
id: string;
label: string;
issuedAt: string;
amountUsd: number;
status: "paid" | "open";
}
export interface MockFoundryBillingState {
planId: MockBillingPlanId;
status: MockBillingStatus;
seatsIncluded: number;
trialEndsAt: string | null;
renewalAt: string | null;
stripeCustomerId: string;
paymentMethodLabel: string;
invoices: MockFoundryInvoice[];
}
export interface MockFoundryGithubState {
connectedAccount: string;
installationStatus: MockGithubInstallationStatus;
syncStatus: MockGithubSyncStatus;
importedRepoCount: number;
lastSyncLabel: string;
lastSyncAt: number | null;
}
export interface MockFoundryOrganizationSettings {
displayName: string;
slug: string;
primaryDomain: string;
seatAccrualMode: "first_prompt";
defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
autoImportRepos: boolean;
}
export interface MockFoundryOrganization {
id: string;
workspaceId: string;
kind: MockOrganizationKind;
settings: MockFoundryOrganizationSettings;
github: MockFoundryGithubState;
billing: MockFoundryBillingState;
members: MockFoundryOrganizationMember[];
seatAssignments: string[];
repoCatalog: string[];
}
export interface MockFoundryAppSnapshot {
auth: {
status: "signed_out" | "signed_in";
currentUserId: string | null;
};
activeOrganizationId: string | null;
onboarding: {
starterRepo: {
repoFullName: string;
repoUrl: string;
status: MockStarterRepoStatus;
starredAt: number | null;
skippedAt: number | null;
};
};
users: MockFoundryUser[];
organizations: MockFoundryOrganization[];
}
export interface UpdateMockOrganizationProfileInput {
organizationId: string;
displayName: string;
slug: string;
primaryDomain: string;
}
export interface MockFoundryAppClient {
getSnapshot(): MockFoundryAppSnapshot;
subscribe(listener: () => void): () => void;
signInWithGithub(userId: string): Promise<void>;
signOut(): Promise<void>;
skipStarterRepo(): Promise<void>;
starStarterRepo(organizationId: string): Promise<void>;
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
recordSeatUsage(workspaceId: string): void;
}
const STORAGE_KEY = "sandbox-agent-foundry:mock-app:v1";
function isoDate(daysFromNow: number): string {
const value = new Date();
value.setDate(value.getDate() + daysFromNow);
return value.toISOString();
}
function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus {
switch (value) {
case "ready":
case "synced":
return "synced";
case "importing":
case "syncing":
return "syncing";
case "error":
return "error";
default:
return "pending";
}
}
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
return {
auth: {
status: "signed_out",
currentUserId: null,
},
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [
{
id: "user-nathan",
name: "Nathan",
email: "nathan@acme.dev",
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
},
{
id: "user-maya",
name: "Maya",
email: "maya@acme.dev",
githubLogin: "maya",
roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"],
},
{
id: "user-jamie",
name: "Jamie",
email: "jamie@rivet.dev",
githubLogin: "jamie",
roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"],
},
],
organizations: [
{
id: "personal-nathan",
workspaceId: "personal-nathan",
kind: "personal",
settings: {
displayName: "Nathan",
slug: "nathan",
primaryDomain: "personal",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
connectedAccount: "nathan",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
lastSyncAt: Date.now() - 60_000,
},
billing: {
planId: "free",
status: "active",
seatsIncluded: 1,
trialEndsAt: null,
renewalAt: null,
stripeCustomerId: "cus_mock_personal_nathan",
paymentMethodLabel: "No card required",
invoices: [],
},
members: [{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" }],
seatAssignments: ["nathan@acme.dev"],
repoCatalog: ["nathan/personal-site"],
},
{
id: "acme",
workspaceId: "acme",
kind: "organization",
settings: {
displayName: "Acme",
slug: "acme",
primaryDomain: "acme.dev",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
connectedAccount: "acme",
installationStatus: "connected",
syncStatus: "pending",
importedRepoCount: 3,
lastSyncLabel: "Waiting for first import",
lastSyncAt: null,
},
billing: {
planId: "team",
status: "active",
seatsIncluded: 5,
trialEndsAt: null,
renewalAt: isoDate(18),
stripeCustomerId: "cus_mock_acme_team",
paymentMethodLabel: "Visa ending in 4242",
invoices: [
{ id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" },
{ id: "inv-acme-000", label: "February 2026", issuedAt: "2026-02-01", amountUsd: 240, status: "paid" },
],
},
members: [
{ id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
{ id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" },
{ id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" },
{ id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" },
],
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
},
{
id: "rivet",
workspaceId: "rivet",
kind: "organization",
settings: {
displayName: "Rivet",
slug: "rivet",
primaryDomain: "rivet.dev",
seatAccrualMode: "first_prompt",
defaultModel: "o3",
autoImportRepos: true,
},
github: {
connectedAccount: "rivet-dev",
installationStatus: "reconnect_required",
syncStatus: "error",
importedRepoCount: 4,
lastSyncLabel: "Sync stalled 2 hours ago",
lastSyncAt: Date.now() - 2 * 60 * 60_000,
},
billing: {
planId: "team",
status: "trialing",
seatsIncluded: 5,
trialEndsAt: isoDate(12),
renewalAt: isoDate(12),
stripeCustomerId: "cus_mock_rivet_team",
paymentMethodLabel: "Visa ending in 4242",
invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
},
members: [
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
{ id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" },
],
seatAssignments: ["jamie@rivet.dev"],
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
},
{
id: "personal-jamie",
workspaceId: "personal-jamie",
kind: "personal",
settings: {
displayName: "Jamie",
slug: "jamie",
primaryDomain: "personal",
seatAccrualMode: "first_prompt",
defaultModel: "claude-opus-4",
autoImportRepos: true,
},
github: {
connectedAccount: "jamie",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced yesterday",
lastSyncAt: Date.now() - 24 * 60 * 60_000,
},
billing: {
planId: "free",
status: "active",
seatsIncluded: 1,
trialEndsAt: null,
renewalAt: null,
stripeCustomerId: "cus_mock_personal_jamie",
paymentMethodLabel: "No card required",
invoices: [],
},
members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }],
seatAssignments: ["jamie@rivet.dev"],
repoCatalog: ["jamie/demo-app"],
},
],
};
}
function parseStoredSnapshot(): MockFoundryAppSnapshot | null {
if (typeof window === "undefined") {
return null;
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as MockFoundryAppSnapshot & {
organizations?: Array<MockFoundryOrganization & { repoImportStatus?: string }>;
};
if (!parsed || typeof parsed !== "object") {
return null;
}
return {
...parsed,
onboarding: {
starterRepo: {
repoFullName: parsed.onboarding?.starterRepo?.repoFullName ?? "rivet-dev/sandbox-agent",
repoUrl: parsed.onboarding?.starterRepo?.repoUrl ?? "https://github.com/rivet-dev/sandbox-agent",
status: parsed.onboarding?.starterRepo?.status ?? "pending",
starredAt: parsed.onboarding?.starterRepo?.starredAt ?? null,
skippedAt: parsed.onboarding?.starterRepo?.skippedAt ?? null,
},
},
organizations: (parsed.organizations ?? []).map((organization: MockFoundryOrganization & { repoImportStatus?: string }) => ({
...organization,
github: {
...organization.github,
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
lastSyncAt: organization.github?.lastSyncAt ?? null,
},
})),
};
} catch {
return null;
}
}
function saveSnapshot(snapshot: MockFoundryAppSnapshot): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
}
function planSeatsIncluded(planId: MockBillingPlanId): number {
switch (planId) {
case "free":
return 1;
case "team":
return 5;
}
}
class MockFoundryAppStore implements MockFoundryAppClient {
private snapshot = parseStoredSnapshot() ?? buildDefaultSnapshot();
private listeners = new Set<() => void>();
private importTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): MockFoundryAppSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async signInWithGithub(userId: string): Promise<void> {
await this.injectAsyncLatency();
const user = this.snapshot.users.find((candidate) => candidate.id === userId);
if (!user) {
throw new Error(`Unknown mock user ${userId}`);
}
this.updateSnapshot((current) => {
const activeOrganizationId = user.eligibleOrganizationIds.length === 1 ? (user.eligibleOrganizationIds[0] ?? null) : null;
return {
...current,
auth: {
status: "signed_in",
currentUserId: userId,
},
activeOrganizationId,
};
});
if (user.eligibleOrganizationIds.length === 1) {
await this.selectOrganization(user.eligibleOrganizationIds[0]!);
}
}
async signOut(): Promise<void> {
await this.injectAsyncLatency();
this.updateSnapshot((current) => ({
...current,
auth: {
status: "signed_out",
currentUserId: null,
},
activeOrganizationId: null,
onboarding: {
starterRepo: {
...current.onboarding.starterRepo,
status: "pending",
starredAt: null,
skippedAt: null,
},
},
}));
}
async skipStarterRepo(): Promise<void> {
await this.injectAsyncLatency();
this.updateSnapshot((current) => ({
...current,
onboarding: {
starterRepo: {
...current.onboarding.starterRepo,
status: "skipped",
skippedAt: Date.now(),
starredAt: null,
},
},
}));
}
async starStarterRepo(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
this.updateSnapshot((current) => ({
...current,
onboarding: {
starterRepo: {
...current.onboarding.starterRepo,
status: "starred",
starredAt: Date.now(),
skippedAt: null,
},
},
}));
}
async selectOrganization(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
const org = this.requireOrganization(organizationId);
this.updateSnapshot((current) => ({
...current,
activeOrganizationId: organizationId,
}));
if (org.github.syncStatus !== "synced") {
await this.triggerGithubSync(organizationId);
}
}
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(input.organizationId);
this.updateOrganization(input.organizationId, (organization) => ({
...organization,
settings: {
...organization.settings,
displayName: input.displayName.trim() || organization.settings.displayName,
slug: input.slug.trim() || organization.settings.slug,
primaryDomain: input.primaryDomain.trim() || organization.settings.primaryDomain,
},
}));
}
async triggerGithubSync(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
const existingTimer = this.importTimers.get(organizationId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.updateOrganization(organizationId, (organization) => ({
...organization,
github: {
...organization.github,
syncStatus: "syncing",
lastSyncLabel: "Syncing repositories...",
},
}));
const timer = setTimeout(() => {
this.updateOrganization(organizationId, (organization) => ({
...organization,
github: {
...organization.github,
importedRepoCount: organization.repoCatalog.length,
installationStatus: "connected",
syncStatus: "synced",
lastSyncLabel: "Synced just now",
lastSyncAt: Date.now(),
},
}));
this.importTimers.delete(organizationId);
}, 1_250);
this.importTimers.set(organizationId, timer);
}
async completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
planId,
status: "active",
seatsIncluded: planSeatsIncluded(planId),
trialEndsAt: null,
renewalAt: isoDate(30),
paymentMethodLabel: "Visa ending in 4242",
invoices: [
{
id: `inv-${organizationId}-${Date.now()}`,
label: `${organization.settings.displayName} ${planId} upgrade`,
issuedAt: new Date().toISOString().slice(0, 10),
amountUsd: planId === "team" ? 240 : 0,
status: "paid",
},
...organization.billing.invoices,
],
},
}));
}
async openBillingPortal(_organizationId: string): Promise<void> {
await this.injectAsyncLatency();
}
async cancelScheduledRenewal(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
status: "scheduled_cancel",
},
}));
}
async resumeSubscription(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
status: "active",
},
}));
}
async reconnectGithub(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);
this.updateOrganization(organizationId, (organization) => ({
...organization,
github: {
...organization.github,
installationStatus: "connected",
syncStatus: "pending",
lastSyncLabel: "Reconnected just now",
lastSyncAt: Date.now(),
},
}));
}
recordSeatUsage(workspaceId: string): void {
const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId);
const currentUser = currentMockUser(this.snapshot);
if (!org || !currentUser) {
return;
}
if (org.seatAssignments.includes(currentUser.email)) {
return;
}
this.updateOrganization(org.id, (organization) => ({
...organization,
seatAssignments: [...organization.seatAssignments, currentUser.email],
}));
}
private injectAsyncLatency(): Promise<void> {
return injectMockLatency();
}
private updateOrganization(organizationId: string, updater: (organization: MockFoundryOrganization) => MockFoundryOrganization): void {
this.updateSnapshot((current) => ({
...current,
organizations: current.organizations.map((organization) => (organization.id === organizationId ? updater(organization) : organization)),
}));
}
private updateSnapshot(updater: (current: MockFoundryAppSnapshot) => MockFoundryAppSnapshot): void {
this.snapshot = updater(this.snapshot);
saveSnapshot(this.snapshot);
for (const listener of this.listeners) {
listener();
}
}
private requireOrganization(organizationId: string): MockFoundryOrganization {
const organization = this.snapshot.organizations.find((candidate) => candidate.id === organizationId);
if (!organization) {
throw new Error(`Unknown mock organization ${organizationId}`);
}
return organization;
}
}
function currentMockUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null {
if (!snapshot.auth.currentUserId) {
return null;
}
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
}
const mockFoundryAppStore = new MockFoundryAppStore();
export function getMockFoundryAppClient(): MockFoundryAppClient {
return mockFoundryAppStore;
}
export function currentMockFoundryUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null {
return currentMockUser(snapshot);
}
export function currentMockFoundryOrganization(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization | null {
if (!snapshot.activeOrganizationId) {
return null;
}
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
}
export function eligibleMockOrganizations(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization[] {
const user = currentMockUser(snapshot);
if (!user) {
return [];
}
const eligible = new Set(user.eligibleOrganizationIds);
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
}

View file

@ -0,0 +1,540 @@
import type {
AddRepoInput,
CreateTaskInput,
FoundryAppSnapshot,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ProviderId,
RepoOverview,
RepoRecord,
RepoStackActionInput,
RepoStackActionResult,
StarSandboxAgentRepoResult,
SwitchResult,
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import type { BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import { getSharedMockWorkbenchClient } from "./workbench-client.js";
interface MockProcessRecord extends SandboxProcessRecord {
logText: string;
}
function notSupported(name: string): never {
throw new Error(`${name} is not supported by the mock backend client.`);
}
function encodeBase64Utf8(value: string): string {
if (typeof Buffer !== "undefined") {
return Buffer.from(value, "utf8").toString("base64");
}
return globalThis.btoa(unescape(encodeURIComponent(value)));
}
function nowMs(): number {
return Date.now();
}
function mockRepoRemote(label: string): string {
return `https://example.test/${label}.git`;
}
function mockCwd(repoLabel: string, taskId: string): string {
return `/mock/${repoLabel.replace(/\//g, "-")}/${taskId}`;
}
function unsupportedAppSnapshot(): FoundryAppSnapshot {
return {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [],
organizations: [],
};
}
function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskRecord["status"] {
if (archived) {
return "archived";
}
return status;
}
export function createMockBackendClient(defaultWorkspaceId = "default"): BackendClient {
const workbench = getSharedMockWorkbenchClient();
const listenersBySandboxId = new Map<string, Set<() => void>>();
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
let nextPid = 4000;
let nextProcessId = 1;
const requireTask = (taskId: string) => {
const task = workbench.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`Unknown mock task ${taskId}`);
}
return task;
};
const ensureProcessList = (sandboxId: string): MockProcessRecord[] => {
const existing = processesBySandboxId.get(sandboxId);
if (existing) {
return existing;
}
const created: MockProcessRecord[] = [];
processesBySandboxId.set(sandboxId, created);
return created;
};
const notifySandbox = (sandboxId: string): void => {
const listeners = listenersBySandboxId.get(sandboxId);
if (!listeners) {
return;
}
for (const listener of [...listeners]) {
listener();
}
};
const buildTaskRecord = (taskId: string): TaskRecord => {
const task = requireTask(taskId);
const cwd = mockCwd(task.repoName, task.id);
const archived = task.status === "archived";
return {
workspaceId: defaultWorkspaceId,
repoId: task.repoId,
repoRemote: mockRepoRemote(task.repoName),
taskId: task.id,
branchName: task.branch,
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,
},
],
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",
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,
};
};
const cloneProcess = (process: MockProcessRecord): MockProcessRecord => ({ ...process });
const createProcessRecord = (sandboxId: string, cwd: string, request: ProcessCreateRequest): MockProcessRecord => {
const processId = `proc_${nextProcessId++}`;
const createdAtMs = nowMs();
const args = request.args ?? [];
const interactive = request.interactive ?? false;
const tty = request.tty ?? false;
const statusLine = interactive && tty ? "Mock terminal session created.\nInteractive transport is unavailable in mock mode.\n" : "Mock process created.\n";
const commandLine = `$ ${[request.command, ...args].join(" ").trim()}\n`;
return {
id: processId,
command: request.command,
args,
createdAtMs,
cwd: request.cwd ?? cwd,
exitCode: null,
exitedAtMs: null,
interactive,
pid: nextPid++,
status: "running",
tty,
logText: `${statusLine}${commandLine}`,
};
};
return {
async getAppSnapshot(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async signInWithGithub(): Promise<void> {
notSupported("signInWithGithub");
},
async signOutApp(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async skipAppStarterRepo(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async starAppStarterRepo(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async selectAppOrganization(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async updateAppOrganizationProfile(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async triggerAppRepoImport(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async reconnectAppGithub(): Promise<void> {
notSupported("reconnectAppGithub");
},
async completeAppHostedCheckout(): Promise<void> {
notSupported("completeAppHostedCheckout");
},
async openAppBillingPortal(): Promise<void> {
notSupported("openAppBillingPortal");
},
async cancelAppScheduledRenewal(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async resumeAppSubscription(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async recordAppSeatUsage(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async addRepo(_workspaceId: string, _remoteUrl: string): Promise<RepoRecord> {
notSupported("addRepo");
},
async listRepos(_workspaceId: string): Promise<RepoRecord[]> {
return workbench.getSnapshot().repos.map((repo) => ({
workspaceId: defaultWorkspaceId,
repoId: repo.id,
remoteUrl: mockRepoRemote(repo.label),
createdAt: nowMs(),
updatedAt: nowMs(),
}));
},
async createTask(_input: CreateTaskInput): Promise<TaskRecord> {
notSupported("createTask");
},
async listTasks(_workspaceId: string, repoId?: string): Promise<TaskSummary[]> {
return workbench
.getSnapshot()
.tasks.filter((task) => !repoId || task.repoId === repoId)
.map((task) => ({
workspaceId: defaultWorkspaceId,
repoId: task.repoId,
taskId: task.id,
branchName: task.branch,
title: task.title,
status: task.status === "archived" ? "archived" : "running",
updatedAt: task.updatedAtMs,
}));
},
async getRepoOverview(_workspaceId: string, _repoId: string): Promise<RepoOverview> {
notSupported("getRepoOverview");
},
async runRepoStackAction(_input: RepoStackActionInput): Promise<RepoStackActionResult> {
notSupported("runRepoStackAction");
},
async getTask(_workspaceId: string, taskId: string): Promise<TaskRecord> {
return buildTaskRecord(taskId);
},
async listHistory(_input: HistoryQueryInput): Promise<HistoryEvent[]> {
return [];
},
async switchTask(_workspaceId: string, taskId: string): Promise<SwitchResult> {
return {
workspaceId: defaultWorkspaceId,
taskId,
providerId: "local",
switchTarget: `mock://${taskId}`,
};
},
async attachTask(_workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
return {
target: `mock://${taskId}`,
sessionId: requireTask(taskId).tabs[0]?.sessionId ?? null,
};
},
async runAction(_workspaceId: string, _taskId: string): Promise<void> {
notSupported("runAction");
},
async createSandboxSession(): Promise<{ id: string; status: "running" | "idle" | "error" }> {
notSupported("createSandboxSession");
},
async listSandboxSessions(): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
return { items: [] };
},
async listSandboxSessionEvents(): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
return { items: [] };
},
async createSandboxProcess(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
request: ProcessCreateRequest;
}): Promise<SandboxProcessRecord> {
const task = requireTask(input.sandboxId);
const processes = ensureProcessList(input.sandboxId);
const created = createProcessRecord(input.sandboxId, mockCwd(task.repoName, task.id), input.request);
processes.unshift(created);
notifySandbox(input.sandboxId);
return cloneProcess(created);
},
async listSandboxProcesses(_workspaceId: string, _providerId: ProviderId, sandboxId: string): Promise<{ processes: SandboxProcessRecord[] }> {
return {
processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)),
};
},
async getSandboxProcessLogs(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessLogFollowQuery,
): Promise<ProcessLogsResponse> {
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
if (!process) {
throw new Error(`Unknown mock process ${processId}`);
}
return {
processId,
stream: query?.stream ?? (process.tty ? "pty" : "combined"),
entries: process.logText
? [
{
data: encodeBase64Utf8(process.logText),
encoding: "base64",
sequence: 1,
stream: query?.stream ?? (process.tty ? "pty" : "combined"),
timestampMs: process.createdAtMs,
},
]
: [],
};
},
async stopSandboxProcess(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
_query?: ProcessSignalQuery,
): Promise<SandboxProcessRecord> {
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
if (!process) {
throw new Error(`Unknown mock process ${processId}`);
}
process.status = "exited";
process.exitCode = 0;
process.exitedAtMs = nowMs();
process.logText += "\n[stopped]\n";
notifySandbox(sandboxId);
return cloneProcess(process);
},
async killSandboxProcess(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
_query?: ProcessSignalQuery,
): Promise<SandboxProcessRecord> {
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
if (!process) {
throw new Error(`Unknown mock process ${processId}`);
}
process.status = "exited";
process.exitCode = 137;
process.exitedAtMs = nowMs();
process.logText += "\n[killed]\n";
notifySandbox(sandboxId);
return cloneProcess(process);
},
async deleteSandboxProcess(_workspaceId: string, _providerId: ProviderId, sandboxId: string, processId: string): Promise<void> {
processesBySandboxId.set(
sandboxId,
ensureProcessList(sandboxId).filter((candidate) => candidate.id !== processId),
);
notifySandbox(sandboxId);
},
subscribeSandboxProcesses(_workspaceId: string, _providerId: ProviderId, sandboxId: string, listener: () => void): () => void {
let listeners = listenersBySandboxId.get(sandboxId);
if (!listeners) {
listeners = new Set();
listenersBySandboxId.set(sandboxId, listeners);
}
listeners.add(listener);
return () => {
const current = listenersBySandboxId.get(sandboxId);
if (!current) {
return;
}
current.delete(listener);
if (current.size === 0) {
listenersBySandboxId.delete(sandboxId);
}
};
},
async sendSandboxPrompt(): Promise<void> {
notSupported("sendSandboxPrompt");
},
async sandboxSessionStatus(sessionId: string): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return { id: sessionId, status: "idle" };
},
async sandboxProviderState(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
return { providerId: "local", sandboxId, state: "running", at: nowMs() };
},
async getSandboxAgentConnection(): Promise<{ endpoint: string; token?: string }> {
return { endpoint: "mock://terminal-unavailable" };
},
async getWorkbench(): Promise<TaskWorkbenchSnapshot> {
return workbench.getSnapshot();
},
subscribeWorkbench(_workspaceId: string, listener: () => void): () => void {
return workbench.subscribe(listener);
},
async createWorkbenchTask(_workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
return await workbench.createTask(input);
},
async markWorkbenchUnread(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await workbench.markTaskUnread(input);
},
async renameWorkbenchTask(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await workbench.renameTask(input);
},
async renameWorkbenchBranch(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await workbench.renameBranch(input);
},
async createWorkbenchSession(_workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
return await workbench.addTab(input);
},
async renameWorkbenchSession(_workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> {
await workbench.renameSession(input);
},
async setWorkbenchSessionUnread(_workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await workbench.setSessionUnread(input);
},
async updateWorkbenchDraft(_workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await workbench.updateDraft(input);
},
async changeWorkbenchModel(_workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
await workbench.changeModel(input);
},
async sendWorkbenchMessage(_workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
await workbench.sendMessage(input);
},
async stopWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await workbench.stopAgent(input);
},
async closeWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await workbench.closeTab(input);
},
async publishWorkbenchPr(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await workbench.publishPr(input);
},
async revertWorkbenchFile(_workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void> {
await workbench.revertFile(input);
},
async health(): Promise<{ ok: true }> {
return { ok: true };
},
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
return { workspaceId };
},
async starSandboxAgentRepo(): Promise<StarSandboxAgentRepoResult> {
return {
repo: "rivet-dev/sandbox-agent",
starredAt: nowMs(),
};
},
};
}

View file

@ -0,0 +1,12 @@
const MOCK_LATENCY_MIN_MS = 1;
const MOCK_LATENCY_MAX_MS = 200;
export function randomMockLatencyMs(): number {
return Math.floor(Math.random() * (MOCK_LATENCY_MAX_MS - MOCK_LATENCY_MIN_MS + 1)) + MOCK_LATENCY_MIN_MS;
}
export function injectMockLatency(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, randomMockLatencyMs());
});
}

View file

@ -0,0 +1,443 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchProjects,
nowMs,
providerAgent,
randomReply,
removeFileTreePath,
slugify,
uid,
} from "../workbench-model.js";
import type {
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
WorkbenchAgentTab as AgentTab,
WorkbenchTask as Task,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import type { TaskWorkbenchClient } from "../workbench-client.js";
function buildTranscriptEvent(params: {
sessionId: string;
sender: "client" | "agent";
createdAt: number;
payload: unknown;
eventIndex: number;
}): TranscriptEvent {
return {
id: uid(),
sessionId: params.sessionId,
sender: params.sender,
createdAt: params.createdAt,
payload: params.payload,
connectionId: "mock-connection",
eventIndex: params.eventIndex,
};
}
class MockWorkbenchStore implements TaskWorkbenchClient {
private snapshot = buildInitialMockLayoutViewModel();
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const id = uid();
const tabId = `session-${id}`;
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
if (!repo) {
throw new Error(`Cannot create mock task for unknown repo ${input.repoId}`);
}
const nextTask: Task = {
id,
repoId: repo.id,
title: input.title?.trim() || "New Task",
status: "new",
repoName: repo.label,
updatedAtMs: nowMs(),
branch: input.branch?.trim() || null,
pullRequest: null,
tabs: [
{
id: tabId,
sessionId: tabId,
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",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
},
],
fileChanges: [],
diffs: {},
fileTree: [],
};
this.updateState((current) => ({
...current,
tasks: [nextTask, ...current.tasks],
}));
return { taskId: id, tabId };
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
this.updateTask(input.taskId, (task) => {
const targetTab = task.tabs[task.tabs.length - 1] ?? null;
if (!targetTab) {
return task;
}
return {
...task,
tabs: task.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
};
});
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename task ${input.taskId} to an empty title`);
}
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> {
this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() }));
}
async publishPr(input: TaskWorkbenchSelectInput): 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" },
}));
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
this.updateTask(input.taskId, (task) => {
const file = task.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...task.diffs };
delete nextDiffs[input.path];
return {
...task,
fileChanges: task.fileChanges.filter((entry) => entry.path !== input.path),
diffs: nextDiffs,
fileTree: file?.type === "A" ? removeFileTreePath(task.fileTree, input.path) : task.fileTree,
};
});
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
this.assertTab(input.taskId, input.tabId);
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
tabs: task.tabs.map((tab) =>
tab.id === input.tabId
? {
...tab,
draft: {
text: input.text,
attachments: input.attachments,
updatedAtMs: nowMs(),
},
}
: tab,
),
}));
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
const text = input.text.trim();
if (!text) {
throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`);
}
this.assertTab(input.taskId, input.tabId);
const startedAtMs = nowMs();
this.updateTask(input.taskId, (currentTask) => {
const isFirstOnTask = currentTask.status === "new";
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}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "client",
createdAt: startedAtMs,
eventIndex: candidateEventIndex(currentTask, input.tabId),
payload: {
method: "session/prompt",
params: {
prompt: userMessageLines.map((line) => ({ type: "text", text: line })),
},
},
});
return {
...currentTask,
title: newTitle,
branch: newBranch,
status: "running",
updatedAtMs: startedAtMs,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId
? {
...candidate,
created: true,
status: "running",
unread: false,
thinkingSinceMs: startedAtMs,
draft: { text: "", attachments: [], updatedAtMs: startedAtMs },
transcript: [...candidate.transcript, userEvent],
}
: candidate,
),
};
});
const existingTimer = this.pendingTimers.get(input.tabId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
const task = this.requireTask(input.taskId);
const replyTab = this.requireTab(task, input.tabId);
const completedAtMs = nowMs();
const replyEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "agent",
createdAt: completedAtMs,
eventIndex: candidateEventIndex(task, input.tabId),
payload: {
result: {
text: randomReply(),
durationMs: completedAtMs - startedAtMs,
},
},
});
this.updateTask(input.taskId, (currentTask) => {
const updatedTabs = currentTask.tabs.map((candidate) => {
if (candidate.id !== input.tabId) {
return candidate;
}
return {
...candidate,
status: "idle" as const,
thinkingSinceMs: null,
unread: true,
transcript: [...candidate.transcript, replyEvent],
};
});
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentTask,
updatedAtMs: completedAtMs,
tabs: updatedTabs,
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
this.pendingTimers.delete(input.tabId);
}, 2_500);
this.pendingTimers.set(input.tabId, timer);
}
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
this.assertTab(input.taskId, input.tabId);
const existing = this.pendingTimers.get(input.tabId);
if (existing) {
clearTimeout(existing);
this.pendingTimers.delete(input.tabId);
}
this.updateTask(input.taskId, (currentTask) => {
const updatedTabs = currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
);
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentTask,
updatedAtMs: nowMs(),
tabs: updatedTabs,
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)),
}));
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
const title = input.title.trim();
if (!title) {
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
}
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)),
}));
}
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => {
if (currentTask.tabs.length <= 1) {
return currentTask;
}
return {
...currentTask,
tabs: currentTask.tabs.filter((candidate) => candidate.id !== input.tabId),
};
});
}
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
this.assertTask(input.taskId);
const nextTab: AgentTab = {
id: uid(),
sessionId: null,
sessionName: `Session ${this.requireTask(input.taskId).tabs.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
};
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
tabs: [...currentTask.tabs, nextTab],
}));
return { tabId: nextTab.id };
}
async changeModel(input: TaskWorkbenchChangeModelInput): 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}`);
}
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
tabs: currentTask.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
),
}));
}
private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void {
const nextSnapshot = updater(this.snapshot);
this.snapshot = {
...nextSnapshot,
projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.tasks),
};
this.notify();
}
private updateTask(taskId: string, updater: (task: Task) => Task): void {
this.assertTask(taskId);
this.updateState((current) => ({
...current,
tasks: current.tasks.map((task) => (task.id === taskId ? updater(task) : task)),
}));
}
private notify(): void {
for (const listener of this.listeners) {
listener();
}
}
private assertTask(taskId: string): void {
this.requireTask(taskId);
}
private assertTab(taskId: string, tabId: string): void {
const task = this.requireTask(taskId);
this.requireTab(task, tabId);
}
private requireTask(taskId: string): Task {
const task = this.snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`Unable to find mock task ${taskId}`);
}
return task;
}
private requireTab(task: Task, tabId: string): AgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`Unable to find mock tab ${tabId} in task ${task.id}`);
}
return tab;
}
}
function candidateEventIndex(task: Task, tabId: string): number {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
return (tab?.transcript.length ?? 0) + 1;
}
let sharedMockWorkbenchClient: TaskWorkbenchClient | null = null;
export function getSharedMockWorkbenchClient(): TaskWorkbenchClient {
if (!sharedMockWorkbenchClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore();
}
return sharedMockWorkbenchClient;
}

View file

@ -0,0 +1,152 @@
import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import type { FoundryAppClient } from "../app-client.js";
export interface RemoteFoundryAppClientOptions {
backend: BackendClient;
}
class RemoteFoundryAppStore implements FoundryAppClient {
private readonly backend: BackendClient;
private snapshot: FoundryAppSnapshot = {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [],
organizations: [],
};
private readonly listeners = new Set<() => void>();
private refreshPromise: Promise<void> | null = null;
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteFoundryAppClientOptions) {
this.backend = options.backend;
}
getSnapshot(): FoundryAppSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
void this.refresh();
return () => {
this.listeners.delete(listener);
};
}
async signInWithGithub(userId?: string): Promise<void> {
void userId;
await this.backend.signInWithGithub();
}
async signOut(): Promise<void> {
this.snapshot = await this.backend.signOutApp();
this.notify();
}
async skipStarterRepo(): Promise<void> {
this.snapshot = await this.backend.skipAppStarterRepo();
this.notify();
}
async starStarterRepo(organizationId: string): Promise<void> {
this.snapshot = await this.backend.starAppStarterRepo(organizationId);
this.notify();
}
async selectOrganization(organizationId: string): Promise<void> {
this.snapshot = await this.backend.selectAppOrganization(organizationId);
this.notify();
this.scheduleSyncPollingIfNeeded();
}
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
this.snapshot = await this.backend.updateAppOrganizationProfile(input);
this.notify();
}
async triggerGithubSync(organizationId: string): Promise<void> {
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
this.notify();
this.scheduleSyncPollingIfNeeded();
}
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
await this.backend.completeAppHostedCheckout(organizationId, planId);
}
async openBillingPortal(organizationId: string): Promise<void> {
await this.backend.openAppBillingPortal(organizationId);
}
async cancelScheduledRenewal(organizationId: string): Promise<void> {
this.snapshot = await this.backend.cancelAppScheduledRenewal(organizationId);
this.notify();
}
async resumeSubscription(organizationId: string): Promise<void> {
this.snapshot = await this.backend.resumeAppSubscription(organizationId);
this.notify();
}
async reconnectGithub(organizationId: string): Promise<void> {
await this.backend.reconnectAppGithub(organizationId);
}
async recordSeatUsage(workspaceId: string): Promise<void> {
this.snapshot = await this.backend.recordAppSeatUsage(workspaceId);
this.notify();
}
private scheduleSyncPollingIfNeeded(): void {
if (this.syncPollTimeout) {
clearTimeout(this.syncPollTimeout);
this.syncPollTimeout = null;
}
if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) {
return;
}
this.syncPollTimeout = setTimeout(() => {
this.syncPollTimeout = null;
void this.refresh();
}, 500);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
this.snapshot = await this.backend.getAppSnapshot();
this.notify();
this.scheduleSyncPollingIfNeeded();
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
private notify(): void {
for (const listener of [...this.listeners]) {
listener();
}
}
}
export function createRemoteFoundryAppClient(options: RemoteFoundryAppClientOptions): FoundryAppClient {
return new RemoteFoundryAppStore(options);
}

View file

@ -0,0 +1,197 @@
import type {
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchProjects } from "../workbench-model.js";
import type { TaskWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
workspaceId: string;
}
class RemoteWorkbenchStore implements TaskWorkbenchClient {
private readonly backend: BackendClient;
private readonly workspaceId: string;
private snapshot: TaskWorkbenchSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkbench: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkbenchClientOptions) {
this.backend = options.backend;
this.workspaceId = options.workspaceId;
this.snapshot = {
workspaceId: options.workspaceId,
repos: [],
projects: [],
tasks: [],
};
}
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkbench) {
this.unsubscribeWorkbench();
this.unsubscribeWorkbench = null;
}
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const created = await this.backend.createWorkbenchTask(this.workspaceId, input);
await this.refresh();
return created;
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.markWorkbenchUnread(this.workspaceId, input);
await this.refresh();
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchTask(this.workspaceId, input);
await this.refresh();
}
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchBranch(this.workspaceId, input);
await this.refresh();
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.workspaceId, input);
await this.refresh();
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
await this.refresh();
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
await this.refresh();
}
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.workspaceId, input);
await this.refresh();
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
const created = await this.backend.createWorkbenchSession(this.workspaceId, input);
await this.refresh();
return created;
}
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
await this.backend.changeWorkbenchModel(this.workspaceId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkbench) {
this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.workspaceId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkbench(this.workspaceId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.tasks),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): TaskWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -0,0 +1,104 @@
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
export const TASK_STATUS_GROUPS = ["queued", "running", "idle", "archived", "killed", "error"] as const;
export type TaskStatusGroup = (typeof TASK_STATUS_GROUPS)[number];
const QUEUED_STATUSES = new Set<TaskStatus>([
"init_bootstrap_db",
"init_enqueue_provision",
"init_ensure_name",
"init_assert_name",
"init_create_sandbox",
"init_ensure_agent",
"init_start_sandbox_instance",
"init_create_session",
"init_write_db",
"init_start_status_sync",
"init_complete",
"archive_stop_status_sync",
"archive_release_sandbox",
"archive_finalize",
"kill_destroy_sandbox",
"kill_finalize",
]);
export function groupTaskStatus(status: TaskStatus): TaskStatusGroup {
if (status === "running") return "running";
if (status === "idle") return "idle";
if (status === "archived") return "archived";
if (status === "killed") return "killed";
if (status === "error") return "error";
if (QUEUED_STATUSES.has(status)) return "queued";
return "queued";
}
function emptyStatusCounts(): Record<TaskStatusGroup, number> {
return {
queued: 0,
running: 0,
idle: 0,
archived: 0,
killed: 0,
error: 0,
};
}
export interface TaskSummary {
total: number;
byStatus: Record<TaskStatusGroup, number>;
byProvider: Record<string, number>;
}
export function fuzzyMatch(target: string, query: string): boolean {
const haystack = target.toLowerCase();
const needle = query.toLowerCase();
let i = 0;
for (const ch of needle) {
i = haystack.indexOf(ch, i);
if (i < 0) {
return false;
}
i += 1;
}
return true;
}
export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
const q = query.trim();
if (!q) {
return rows;
}
return rows.filter((row) => {
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
return fields.some((field) => fuzzyMatch(field, q));
});
}
export function formatRelativeAge(updatedAt: number, now = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000));
if (deltaSeconds < 60) return `${deltaSeconds}s`;
const minutes = Math.floor(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function summarizeTasks(rows: TaskRecord[]): TaskSummary {
const byStatus = emptyStatusCounts();
const byProvider: Record<string, number> = {};
for (const row of rows) {
byStatus[groupTaskStatus(row.status)] += 1;
byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1;
}
return {
total: rows.length,
byStatus,
byProvider,
};
}

View file

@ -0,0 +1,64 @@
import type {
TaskWorkbenchAddTabResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type TaskWorkbenchClientMode = "mock" | "remote";
export interface CreateTaskWorkbenchClientOptions {
mode: TaskWorkbenchClientMode;
backend?: BackendClient;
workspaceId?: string;
}
export interface TaskWorkbenchClient {
getSnapshot(): TaskWorkbenchSnapshot;
subscribe(listener: () => void): () => void;
createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameTask(input: TaskWorkbenchRenameInput): Promise<void>;
renameBranch(input: TaskWorkbenchRenameInput): Promise<void>;
archiveTask(input: TaskWorkbenchSelectInput): Promise<void>;
publishPr(input: TaskWorkbenchSelectInput): Promise<void>;
revertFile(input: TaskWorkbenchDiffInput): Promise<void>;
updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkbenchTabInput): Promise<void>;
setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
closeTab(input: TaskWorkbenchTabInput): Promise<void>;
addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse>;
changeModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
}
export function createTaskWorkbenchClient(options: CreateTaskWorkbenchClientOptions): TaskWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
}
if (!options.backend) {
throw new Error("Remote task workbench client requires a backend client");
}
if (!options.workspaceId) {
throw new Error("Remote task workbench client requires a workspace id");
}
return createRemoteWorkbenchClient({
backend: options.backend,
workspaceId: options.workspaceId,
});
}

File diff suppressed because it is too large Load diff