sandbox-agent/foundry/packages/client/src/mock-app.ts
2026-03-12 11:21:14 -07:00

751 lines
23 KiB
TypeScript

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<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>;
clearOrganizationRuntimeIssues(organizationId: string, actorId?: 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 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<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,
},
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<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 clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void> {
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<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));
}