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 type MockActorRuntimeStatus = "healthy" | "error"; export type MockActorRuntimeType = "organization" | "repository" | "task" | "history" | "sandbox_instance" | "task_status_sync"; 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 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; 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; runtime: MockFoundryActorRuntimeState; 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; signOut(): Promise; skipStarterRepo(): Promise; starStarterRepo(organizationId: string): Promise; selectOrganization(organizationId: string): Promise; updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise; triggerGithubSync(organizationId: string): Promise; clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise; completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise; openBillingPortal(organizationId: string): Promise; cancelScheduledRenewal(organizationId: string): Promise; resumeSubscription(organizationId: string): Promise; reconnectGithub(organizationId: string): Promise; 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 buildHealthyRuntimeState(): MockFoundryActorRuntimeState { return { status: "healthy", errorCount: 0, lastErrorAt: null, issues: [], }; } 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, }, runtime: buildHealthyRuntimeState(), 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, }, runtime: buildHealthyRuntimeState(), 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, }, runtime: buildHealthyRuntimeState(), 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, }, runtime: buildHealthyRuntimeState(), 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; }; 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, }, runtime: organization.runtime ?? buildHealthyRuntimeState(), })), }; } 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>(); getSnapshot(): MockFoundryAppSnapshot { return this.snapshot; } subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } async signInWithGithub(userId: string): Promise { 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 { 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 { await this.injectAsyncLatency(); this.updateSnapshot((current) => ({ ...current, onboarding: { starterRepo: { ...current.onboarding.starterRepo, status: "skipped", skippedAt: Date.now(), starredAt: null, }, }, })); } async starStarterRepo(organizationId: string): Promise { 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 { 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 { 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 { 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 clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise { await this.injectAsyncLatency(); void actorId; this.requireOrganization(organizationId); this.updateOrganization(organizationId, (organization) => ({ ...organization, runtime: { ...organization.runtime, status: "healthy", errorCount: 0, issues: [], }, })); } async completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise { 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 { await this.injectAsyncLatency(); } async cancelScheduledRenewal(organizationId: string): Promise { await this.injectAsyncLatency(); this.requireOrganization(organizationId); this.updateOrganization(organizationId, (organization) => ({ ...organization, billing: { ...organization.billing, status: "scheduled_cancel", }, })); } async resumeSubscription(organizationId: string): Promise { await this.injectAsyncLatency(); this.requireOrganization(organizationId); this.updateOrganization(organizationId, (organization) => ({ ...organization, billing: { ...organization.billing, status: "active", }, })); } async reconnectGithub(organizationId: string): Promise { 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 { 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)); }