Finalize Foundry sync flow

This commit is contained in:
Nathan Flurry 2026-03-12 17:19:26 -07:00
parent 5c70cbcd23
commit 1c852cc5f8
14 changed files with 768 additions and 187 deletions

View file

@ -6,6 +6,12 @@ const journal = {
tag: "0000_github_state",
breakpoints: true,
},
{
idx: 1,
when: 1773340800000,
tag: "0001_github_state_sync_progress",
breakpoints: true,
},
],
} as const;
@ -53,6 +59,12 @@ CREATE TABLE \`github_pull_requests\` (
\`is_draft\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`github_meta\` ADD \`sync_phase\` text;
ALTER TABLE \`github_meta\` ADD \`sync_run_started_at\` integer;
ALTER TABLE \`github_meta\` ADD \`sync_repositories_total\` integer;
ALTER TABLE \`github_meta\` ADD \`sync_repositories_completed\` integer;
ALTER TABLE \`github_meta\` ADD \`sync_pull_request_repositories_completed\` integer;
`,
} as const,
};

View file

@ -8,6 +8,11 @@ export const githubMeta = sqliteTable("github_meta", {
installationId: integer("installation_id"),
lastSyncLabel: text("last_sync_label").notNull(),
lastSyncAt: integer("last_sync_at"),
syncPhase: text("sync_phase"),
syncRunStartedAt: integer("sync_run_started_at"),
syncRepositoriesTotal: integer("sync_repositories_total"),
syncRepositoriesCompleted: integer("sync_repositories_completed"),
syncPullRequestRepositoriesCompleted: integer("sync_pull_request_repositories_completed"),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -1,15 +1,20 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { actor } from "rivetkit";
import { randomUUID } from "node:crypto";
import { eq, lt } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { FoundryGithubInstallationStatus, FoundryGithubSyncStatus } from "@sandbox-agent/foundry-shared";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateOrganization, getOrCreateRepository, selfGithubState } from "../handles.js";
import { APP_SHELL_ORGANIZATION_ID } from "../organization/app-shell.js";
import { githubStateDb } from "./db/db.js";
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
const META_ROW_ID = 1;
const GITHUB_PR_BATCH_SIZE = 10;
const GITHUB_QUEUE_NAMES = ["github.command.full_sync"] as const;
interface GithubStateInput {
organizationId: string;
@ -22,6 +27,11 @@ interface GithubStateMeta {
installationId: number | null;
lastSyncLabel: string;
lastSyncAt: number | null;
syncPhase: string | null;
syncRunStartedAt: number | null;
syncRepositoriesTotal: number | null;
syncRepositoriesCompleted: number;
syncPullRequestRepositoriesCompleted: number;
}
interface SyncMemberSeed {
@ -42,6 +52,12 @@ interface FullSyncInput {
accessToken?: string | null;
label?: string;
fallbackMembers?: SyncMemberSeed[];
force?: boolean;
}
interface FullSyncCommand extends FullSyncInput {
runId: string;
runStartedAt: number;
}
interface PullRequestWebhookInput {
@ -67,6 +83,36 @@ interface PullRequestWebhookInput {
};
}
interface GitHubRepositorySnapshot {
fullName: string;
cloneUrl: string;
private: boolean;
}
interface GitHubPullRequestSnapshot {
repoFullName: string;
cloneUrl: string;
number: number;
title: string;
body?: string | null;
state: string;
url: string;
headRefName: string;
baseRefName: string;
authorLogin?: string | null;
isDraft?: boolean;
}
interface FullSyncSeed {
repositories: GitHubRepositorySnapshot[];
members: SyncMemberSeed[];
pullRequestSource: "installation" | "user" | "none";
}
function githubWorkflowQueueName(name: string): string {
return name;
}
function normalizePullRequestStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "draft" | "ready" | "closed" | "merged" {
const rawState = input.state.trim().toUpperCase();
if (input.merged || rawState === "MERGED") {
@ -78,24 +124,13 @@ function normalizePullRequestStatus(input: { state: string; isDraft?: boolean; m
return input.isDraft ? "draft" : "ready";
}
interface FullSyncSnapshot {
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
members: SyncMemberSeed[];
loadPullRequests: () => Promise<
Array<{
repoFullName: string;
cloneUrl: string;
number: number;
title: string;
body?: string | null;
state: string;
url: string;
headRefName: string;
baseRefName: string;
authorLogin?: string | null;
isDraft?: boolean;
}>
>;
function repoBelongsToAccount(fullName: string, accountLogin: string): boolean {
const owner = fullName.split("/")[0]?.trim().toLowerCase() ?? "";
return owner.length > 0 && owner === accountLogin.trim().toLowerCase();
}
function batchLabel(completed: number, total: number): string {
return `Syncing pull requests (${completed}/${total} repositories)...`;
}
async function readMeta(c: any): Promise<GithubStateMeta> {
@ -107,6 +142,11 @@ async function readMeta(c: any): Promise<GithubStateMeta> {
installationId: row?.installationId ?? null,
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first sync",
lastSyncAt: row?.lastSyncAt ?? null,
syncPhase: row?.syncPhase ?? null,
syncRunStartedAt: row?.syncRunStartedAt ?? null,
syncRepositoriesTotal: row?.syncRepositoriesTotal ?? null,
syncRepositoriesCompleted: row?.syncRepositoriesCompleted ?? 0,
syncPullRequestRepositoriesCompleted: row?.syncPullRequestRepositoriesCompleted ?? 0,
};
}
@ -126,6 +166,11 @@ async function writeMeta(c: any, patch: Partial<GithubStateMeta>): Promise<Githu
installationId: next.installationId,
lastSyncLabel: next.lastSyncLabel,
lastSyncAt: next.lastSyncAt,
syncPhase: next.syncPhase,
syncRunStartedAt: next.syncRunStartedAt,
syncRepositoriesTotal: next.syncRepositoriesTotal,
syncRepositoriesCompleted: next.syncRepositoriesCompleted,
syncPullRequestRepositoriesCompleted: next.syncPullRequestRepositoriesCompleted,
updatedAt: Date.now(),
})
.onConflictDoUpdate({
@ -137,6 +182,11 @@ async function writeMeta(c: any, patch: Partial<GithubStateMeta>): Promise<Githu
installationId: next.installationId,
lastSyncLabel: next.lastSyncLabel,
lastSyncAt: next.lastSyncAt,
syncPhase: next.syncPhase,
syncRunStartedAt: next.syncRunStartedAt,
syncRepositoriesTotal: next.syncRepositoriesTotal,
syncRepositoriesCompleted: next.syncRepositoriesCompleted,
syncPullRequestRepositoriesCompleted: next.syncPullRequestRepositoriesCompleted,
updatedAt: Date.now(),
},
})
@ -144,9 +194,17 @@ async function writeMeta(c: any, patch: Partial<GithubStateMeta>): Promise<Githu
return next;
}
async function replaceRepositories(c: any, repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>): Promise<void> {
await c.db.delete(githubRepositories).run();
const now = Date.now();
async function notifyAppUpdated(c: any): Promise<void> {
const app = await getOrCreateOrganization(c, APP_SHELL_ORGANIZATION_ID);
await app.notifyAppUpdated({});
}
async function notifyOrganizationUpdated(c: any): Promise<void> {
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await Promise.all([organization.notifyWorkbenchUpdated({}), notifyAppUpdated(c)]);
}
async function upsertRepositories(c: any, repositories: GitHubRepositorySnapshot[], updatedAt: number): Promise<void> {
for (const repository of repositories) {
await c.db
.insert(githubRepositories)
@ -155,15 +213,22 @@ async function replaceRepositories(c: any, repositories: Array<{ fullName: strin
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
updatedAt: now,
updatedAt,
})
.onConflictDoUpdate({
target: githubRepositories.repoId,
set: {
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
updatedAt,
},
})
.run();
}
}
async function replaceMembers(c: any, members: SyncMemberSeed[]): Promise<void> {
await c.db.delete(githubMembers).run();
const now = Date.now();
async function upsertMembers(c: any, members: SyncMemberSeed[], updatedAt: number): Promise<void> {
for (const member of members) {
await c.db
.insert(githubMembers)
@ -174,30 +239,24 @@ async function replaceMembers(c: any, members: SyncMemberSeed[]): Promise<void>
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
updatedAt: now,
updatedAt,
})
.onConflictDoUpdate({
target: githubMembers.memberId,
set: {
login: member.login,
displayName: member.name || member.login,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
updatedAt,
},
})
.run();
}
}
async function replacePullRequests(
c: any,
pullRequests: Array<{
repoFullName: string;
cloneUrl: string;
number: number;
title: string;
body?: string | null;
state: string;
url: string;
headRefName: string;
baseRefName: string;
authorLogin?: string | null;
isDraft?: boolean;
}>,
): Promise<void> {
await c.db.delete(githubPullRequests).run();
const now = Date.now();
async function upsertPullRequests(c: any, pullRequests: GitHubPullRequestSnapshot[], updatedAt: number): Promise<void> {
for (const pullRequest of pullRequests) {
const repoId = repoIdFromRemote(pullRequest.cloneUrl);
await c.db
@ -215,12 +274,34 @@ async function replacePullRequests(
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt: now,
updatedAt,
})
.onConflictDoUpdate({
target: githubPullRequests.prId,
set: {
repoId,
repoFullName: pullRequest.repoFullName,
title: pullRequest.title,
body: pullRequest.body ?? null,
state: pullRequest.state,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
},
})
.run();
}
}
async function pruneStaleRows(c: any, runStartedAt: number): Promise<void> {
await c.db.delete(githubRepositories).where(lt(githubRepositories.updatedAt, runStartedAt)).run();
await c.db.delete(githubMembers).where(lt(githubMembers.updatedAt, runStartedAt)).run();
await c.db.delete(githubPullRequests).where(lt(githubPullRequests.updatedAt, runStartedAt)).run();
}
async function upsertPullRequest(c: any, input: PullRequestWebhookInput): Promise<void> {
const repoId = repoIdFromRemote(input.repository.cloneUrl);
const now = Date.now();
@ -340,13 +421,206 @@ async function countRows(c: any) {
};
}
function repoBelongsToAccount(fullName: string, accountLogin: string): boolean {
const owner = fullName.split("/")[0]?.trim().toLowerCase() ?? "";
return owner.length > 0 && owner === accountLogin.trim().toLowerCase();
async function resolveFullSyncSeed(c: any, input: FullSyncCommand): Promise<FullSyncSeed> {
const { appShell } = getActorRuntimeContext();
const syncFromUserToken = async (): Promise<FullSyncSeed> => {
const rawRepositories = input.accessToken ? await appShell.github.listUserRepositories(input.accessToken) : [];
const repositories =
input.kind === "organization" ? rawRepositories.filter((repository) => repoBelongsToAccount(repository.fullName, input.githubLogin)) : rawRepositories;
const members =
input.accessToken && input.kind === "organization"
? await appShell.github.listOrganizationMembers(input.accessToken, input.githubLogin)
: (input.fallbackMembers ?? []).map((member) => ({
id: member.id,
login: member.login,
name: member.name,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
}));
return {
repositories,
members,
pullRequestSource: input.accessToken ? "user" : "none",
};
};
if (input.installationId != null) {
try {
const repositories = await appShell.github.listInstallationRepositories(input.installationId);
const members =
input.kind === "organization"
? await appShell.github.listInstallationMembers(input.installationId, input.githubLogin)
: (input.fallbackMembers ?? []).map((member) => ({
id: member.id,
login: member.login,
name: member.name,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
}));
return {
repositories,
members,
pullRequestSource: "installation",
};
} catch (error) {
if (!input.accessToken) {
throw error;
}
return await syncFromUserToken();
}
}
return await syncFromUserToken();
}
async function loadPullRequestsForBatch(c: any, input: FullSyncCommand, source: FullSyncSeed["pullRequestSource"], repositories: GitHubRepositorySnapshot[]) {
const { appShell } = getActorRuntimeContext();
if (repositories.length === 0) {
return [];
}
if (source === "installation" && input.installationId != null) {
try {
return await appShell.github.listInstallationPullRequestsForRepositories(input.installationId, repositories);
} catch (error) {
if (!input.accessToken) {
throw error;
}
}
}
if (source === "user" && input.accessToken) {
return await appShell.github.listPullRequestsForUserRepositories(input.accessToken, repositories);
}
return [];
}
async function runFullSyncWorkflow(loopCtx: any, msg: any): Promise<void> {
const body = msg.body as FullSyncCommand;
const stepPrefix = `github-sync-${body.runId}`;
let completionSummary: Awaited<ReturnType<typeof readMeta>> & Awaited<ReturnType<typeof countRows>>;
try {
const seed = await loopCtx.step({
name: `${stepPrefix}-resolve-seed`,
timeout: 5 * 60_000,
run: async () => resolveFullSyncSeed(loopCtx, body),
});
await loopCtx.step(`${stepPrefix}-write-repositories`, async () => {
await upsertRepositories(loopCtx, seed.repositories, body.runStartedAt);
const organization = await getOrCreateOrganization(loopCtx, loopCtx.state.organizationId);
await organization.applyOrganizationRepositoryCatalog({
repositories: seed.repositories,
});
await writeMeta(loopCtx, {
connectedAccount: body.connectedAccount,
installationStatus: body.installationStatus,
installationId: body.installationId,
syncStatus: "syncing",
syncPhase: "repositories",
syncRunStartedAt: body.runStartedAt,
syncRepositoriesTotal: seed.repositories.length,
syncRepositoriesCompleted: seed.repositories.length,
syncPullRequestRepositoriesCompleted: 0,
lastSyncLabel: seed.repositories.length > 0 ? batchLabel(0, seed.repositories.length) : "No repositories available",
});
await notifyAppUpdated(loopCtx);
});
await loopCtx.step(`${stepPrefix}-write-members`, async () => {
await upsertMembers(loopCtx, seed.members, body.runStartedAt);
await writeMeta(loopCtx, {
syncPhase: "pull_requests",
});
await notifyAppUpdated(loopCtx);
});
for (let start = 0; start < seed.repositories.length; start += GITHUB_PR_BATCH_SIZE) {
const batch = seed.repositories.slice(start, start + GITHUB_PR_BATCH_SIZE);
const completed = Math.min(start + batch.length, seed.repositories.length);
await loopCtx.step({
name: `${stepPrefix}-pull-requests-${start}`,
timeout: 5 * 60_000,
run: async () => {
const pullRequests = await loadPullRequestsForBatch(loopCtx, body, seed.pullRequestSource, batch);
await upsertPullRequests(loopCtx, pullRequests, Date.now());
await writeMeta(loopCtx, {
syncPhase: "pull_requests",
syncPullRequestRepositoriesCompleted: completed,
lastSyncLabel: batchLabel(completed, seed.repositories.length),
});
await notifyAppUpdated(loopCtx);
},
});
}
await loopCtx.step(`${stepPrefix}-finalize`, async () => {
await pruneStaleRows(loopCtx, body.runStartedAt);
const lastSyncLabel = seed.repositories.length > 0 ? `Synced ${seed.repositories.length} repositories` : "No repositories available";
await writeMeta(loopCtx, {
connectedAccount: body.connectedAccount,
installationStatus: body.installationStatus,
installationId: body.installationId,
syncStatus: "synced",
lastSyncLabel,
lastSyncAt: Date.now(),
syncPhase: null,
syncRunStartedAt: null,
syncRepositoriesTotal: seed.repositories.length,
syncRepositoriesCompleted: seed.repositories.length,
syncPullRequestRepositoriesCompleted: seed.repositories.length,
});
completionSummary = {
...(await readMeta(loopCtx)),
...(await countRows(loopCtx)),
};
await notifyOrganizationUpdated(loopCtx);
});
await msg.complete(completionSummary);
} catch (error) {
await loopCtx.step(`${stepPrefix}-failed`, async () => {
const message = error instanceof Error ? error.message : "GitHub sync failed";
const installationStatus = error instanceof Error && /403|404|401/.test(error.message) ? "reconnect_required" : body.installationStatus;
await writeMeta(loopCtx, {
connectedAccount: body.connectedAccount,
installationStatus,
installationId: body.installationId,
syncStatus: "error",
lastSyncLabel: message,
syncPhase: null,
syncRunStartedAt: null,
});
await notifyAppUpdated(loopCtx);
});
throw error;
}
}
async function runGithubStateWorkflow(ctx: any): Promise<void> {
await ctx.loop("github-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-github-command", {
names: [...GITHUB_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "github.command.full_sync") {
await runFullSyncWorkflow(loopCtx, msg);
return Loop.continue(undefined);
}
return Loop.continue(undefined);
});
}
export const githubState = actor({
db: githubStateDb,
queues: Object.fromEntries(GITHUB_QUEUE_NAMES.map((name) => [name, queue()])),
createState: (_c, input: GithubStateInput) => ({
organizationId: input.organizationId,
}),
@ -423,111 +697,69 @@ export const githubState = actor({
syncStatus: input.installationStatus === "connected" ? "pending" : "error",
lastSyncLabel: input.label,
lastSyncAt: null,
syncPhase: null,
syncRunStartedAt: null,
syncRepositoriesTotal: null,
syncRepositoriesCompleted: 0,
syncPullRequestRepositoriesCompleted: 0,
});
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyOrganizationRepositoryCatalog({
repositories: [],
});
await notifyOrganizationUpdated(c);
},
async fullSync(c, input: FullSyncInput) {
const { appShell } = getActorRuntimeContext();
const organization = await getOrCreateOrganization(c, c.state.organizationId);
const current = await readMeta(c);
const counts = await countRows(c);
const currentSummary = {
...current,
...counts,
};
const matchesCurrentTarget =
current.connectedAccount === input.connectedAccount &&
current.installationStatus === input.installationStatus &&
current.installationId === input.installationId;
if (!input.force && current.syncStatus === "syncing") {
return currentSummary;
}
if (!input.force && matchesCurrentTarget && current.syncStatus === "synced" && counts.repositoryCount > 0) {
return currentSummary;
}
const runId = randomUUID();
const runStartedAt = Date.now();
await writeMeta(c, {
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
syncStatus: "syncing",
lastSyncLabel: input.label ?? "Syncing GitHub data...",
lastSyncLabel: input.label ?? "Queued GitHub sync...",
syncPhase: "queued",
syncRunStartedAt: runStartedAt,
syncRepositoriesTotal: null,
syncRepositoriesCompleted: 0,
syncPullRequestRepositoriesCompleted: 0,
});
await notifyAppUpdated(c);
try {
const syncFromUserToken = async (): Promise<FullSyncSnapshot> => {
const rawRepositories = input.accessToken ? await appShell.github.listUserRepositories(input.accessToken) : [];
const repositories =
input.kind === "organization"
? rawRepositories.filter((repository) => repoBelongsToAccount(repository.fullName, input.githubLogin))
: rawRepositories;
const members =
input.accessToken && input.kind === "organization"
? await appShell.github.listOrganizationMembers(input.accessToken, input.githubLogin)
: (input.fallbackMembers ?? []).map((member) => ({
id: member.id,
login: member.login,
name: member.name,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
}));
return {
repositories,
members,
loadPullRequests: async () => (input.accessToken ? await appShell.github.listPullRequestsForUserRepositories(input.accessToken, repositories) : []),
};
};
const self = selfGithubState(c);
await self.send(
githubWorkflowQueueName("github.command.full_sync"),
{
...input,
runId,
runStartedAt,
} satisfies FullSyncCommand,
{
wait: false,
},
);
const { repositories, members, loadPullRequests } =
input.installationId != null
? await (async (): Promise<FullSyncSnapshot> => {
try {
const repositories = await appShell.github.listInstallationRepositories(input.installationId!);
const members =
input.kind === "organization"
? await appShell.github.listInstallationMembers(input.installationId!, input.githubLogin)
: (input.fallbackMembers ?? []).map((member) => ({
id: member.id,
login: member.login,
name: member.name,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
}));
return {
repositories,
members,
loadPullRequests: async () => await appShell.github.listInstallationPullRequests(input.installationId!),
};
} catch (error) {
if (!input.accessToken) {
throw error;
}
return await syncFromUserToken();
}
})()
: await syncFromUserToken();
await replaceRepositories(c, repositories);
await organization.applyOrganizationRepositoryCatalog({
repositories,
});
await replaceMembers(c, members);
const pullRequests = await loadPullRequests();
await replacePullRequests(c, pullRequests);
const lastSyncLabel = repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available";
await writeMeta(c, {
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
syncStatus: "synced",
lastSyncLabel,
lastSyncAt: Date.now(),
});
} catch (error) {
const message = error instanceof Error ? error.message : "GitHub sync failed";
const installationStatus = error instanceof Error && /403|404|401/.test(error.message) ? "reconnect_required" : input.installationStatus;
await writeMeta(c, {
connectedAccount: input.connectedAccount,
installationStatus,
installationId: input.installationId,
syncStatus: "error",
lastSyncLabel: message,
});
throw error;
}
return await selfGithubState(c).getSummary();
return await self.getSummary();
},
async handlePullRequestWebhook(c, input: PullRequestWebhookInput): Promise<void> {
@ -539,6 +771,8 @@ export const githubState = actor({
syncStatus: "synced",
lastSyncLabel: `Updated PR #${input.pullRequest.number}`,
lastSyncAt: Date.now(),
syncPhase: null,
syncRunStartedAt: null,
});
const repository = await getOrCreateRepository(c, c.state.organizationId, repoIdFromRemote(input.repository.cloneUrl), input.repository.cloneUrl);
@ -546,6 +780,7 @@ export const githubState = actor({
branchName: input.pullRequest.headRefName,
state: input.pullRequest.state,
});
await notifyOrganizationUpdated(c);
},
async createPullRequest(
@ -608,7 +843,10 @@ export const githubState = actor({
syncStatus: "synced",
lastSyncLabel: `Linked existing PR #${existing.number}`,
lastSyncAt: now,
syncPhase: null,
syncRunStartedAt: null,
});
await notifyOrganizationUpdated(c);
return created;
}
@ -633,7 +871,10 @@ export const githubState = actor({
syncStatus: "synced",
lastSyncLabel: `Created PR #${created.number}`,
lastSyncAt: now,
syncPhase: null,
syncRunStartedAt: null,
});
await notifyOrganizationUpdated(c);
return created;
},
@ -646,4 +887,5 @@ export const githubState = actor({
});
},
},
run: workflow(runGithubStateWorkflow),
});

View file

@ -610,6 +610,11 @@ export const workspaceAppActions = {
return await buildAppSnapshot(c, input.sessionId);
},
async notifyAppUpdated(c: any): Promise<void> {
assertAppWorkspace(c);
c.broadcast("appUpdated", { at: Date.now() });
},
async resolveAppGithubToken(
c: any,
input: { organizationId: string; requireRepoScope?: boolean },
@ -785,6 +790,7 @@ export const workspaceAppActions = {
installationId: organization.snapshot.kind === "personal" ? null : organization.githubInstallationId,
accessToken: auth.accessToken,
label: "Syncing GitHub data...",
force: true,
fallbackMembers:
organization.snapshot.kind === "personal"
? [
@ -1052,8 +1058,8 @@ export const workspaceAppActions = {
const { appShell } = getActorRuntimeContext();
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
const accountLogin = body.installation?.account?.login;
const accountType = body.installation?.account?.type;
const accountLogin = body.installation?.account?.login ?? body.repository?.owner?.login ?? body.organization?.login ?? null;
const accountType = body.installation?.account?.type ?? body.repository?.owner?.type ?? (body.organization?.login ? "Organization" : null);
if (!accountLogin) {
console.log(`[github-webhook] Ignoring ${event}.${body.action ?? ""}: no installation account`);
return { ok: true };
@ -1080,6 +1086,7 @@ export const workspaceAppActions = {
installationStatus: "connected",
installationId: body.installation?.id ?? null,
label: "Syncing GitHub data from installation webhook...",
force: true,
fallbackMembers: [],
});
} else if (body.action === "suspend") {
@ -1097,6 +1104,7 @@ export const workspaceAppActions = {
installationStatus: "connected",
installationId: body.installation?.id ?? null,
label: "Resyncing GitHub data after unsuspend...",
force: true,
fallbackMembers: [],
});
}
@ -1114,6 +1122,7 @@ export const workspaceAppActions = {
installationStatus: "connected",
installationId: body.installation?.id ?? null,
label: "Resyncing GitHub data after repository access change...",
force: true,
fallbackMembers: [],
});
return { ok: true };

View file

@ -73,7 +73,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
const notifications = createNotificationService(backends);
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
registry.startRunner();
await registry.startRunner();
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
const actorClient = createClient({
endpoint: managerOrigin,

View file

@ -408,6 +408,11 @@ export class GitHubAppClient {
return await this.listPullRequestsForRepositories(repositories, accessToken);
}
async listInstallationPullRequestsForRepositories(installationId: number, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
const accessToken = await this.createInstallationAccessToken(installationId);
return await this.listPullRequestsForRepositories(repositories, accessToken);
}
async listUserPullRequests(accessToken: string): Promise<GitHubPullRequestRecord[]> {
const repositories = await this.listUserRepositories(accessToken);
return await this.listPullRequestsForRepositories(repositories, accessToken);

View file

@ -156,6 +156,7 @@ export interface BackendMetadata {
export interface BackendClient {
getAppSnapshot(): Promise<FoundryAppSnapshot>;
subscribeApp(listener: () => void): () => void;
signInWithGithub(): Promise<void>;
signOutApp(): Promise<FoundryAppSnapshot>;
skipAppStarterRepo(): Promise<FoundryAppSnapshot>;
@ -405,6 +406,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
}
>();
const appSubscriptions = {
listeners: new Set<() => void>(),
disposeConnPromise: null as Promise<(() => Promise<void>) | null> | null,
};
const sandboxProcessSubscriptions = new Map<
string,
{
@ -664,6 +669,66 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
};
};
const subscribeApp = (listener: () => void): (() => void) => {
appSubscriptions.listeners.add(listener);
const ensureConnection = () => {
if (appSubscriptions.disposeConnPromise) {
return;
}
let reconnecting = false;
let disposeConnPromise: Promise<(() => Promise<void>) | null> | null = null;
disposeConnPromise = (async () => {
const handle = await workspace("app");
const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("appUpdated", () => {
for (const currentListener of [...appSubscriptions.listeners]) {
currentListener();
}
});
const unsubscribeError = conn.onError(() => {
if (reconnecting) {
return;
}
reconnecting = true;
if (appSubscriptions.disposeConnPromise !== disposeConnPromise) {
return;
}
appSubscriptions.disposeConnPromise = null;
void disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
if (appSubscriptions.listeners.size > 0) {
ensureConnection();
for (const currentListener of [...appSubscriptions.listeners]) {
currentListener();
}
}
});
return async () => {
unsubscribeEvent();
unsubscribeError();
await conn.dispose();
};
})().catch(() => null);
appSubscriptions.disposeConnPromise = disposeConnPromise;
};
ensureConnection();
return () => {
appSubscriptions.listeners.delete(listener);
if (appSubscriptions.listeners.size > 0) {
return;
}
void appSubscriptions.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
appSubscriptions.disposeConnPromise = null;
};
};
const sandboxProcessSubscriptionKey = (workspaceId: string, providerId: ProviderId, sandboxId: string): string => `${workspaceId}:${providerId}:${sandboxId}`;
const subscribeSandboxProcesses = (workspaceId: string, providerId: ProviderId, sandboxId: string, listener: () => void): (() => void) => {
@ -723,6 +788,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
},
subscribeApp(listener: () => void): () => void {
return subscribeApp(listener);
},
async signInWithGithub(): Promise<void> {
if (typeof window !== "undefined") {
window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`);

View file

@ -548,15 +548,11 @@ class MockFoundryAppStore implements MockFoundryAppClient {
async selectOrganization(organizationId: string): Promise<void> {
await this.injectAsyncLatency();
const org = this.requireOrganization(organizationId);
this.requireOrganization(organizationId);
this.updateSnapshot((current) => ({
...current,
activeOrganizationId: organizationId,
}));
if (org.github.syncStatus !== "synced") {
await this.triggerGithubSync(organizationId);
}
}
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {

View file

@ -225,6 +225,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
return unsupportedAppSnapshot();
},
subscribeApp(): () => void {
return () => {};
},
async signInWithGithub(): Promise<void> {
notSupported("signInWithGithub");
},

View file

@ -25,7 +25,7 @@ class RemoteFoundryAppStore implements FoundryAppClient {
};
private readonly listeners = new Set<() => void>();
private refreshPromise: Promise<void> | null = null;
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
private disposeBackendSubscription: (() => void) | null = null;
constructor(options: RemoteFoundryAppClientOptions) {
this.backend = options.backend;
@ -37,9 +37,18 @@ class RemoteFoundryAppStore implements FoundryAppClient {
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
if (!this.disposeBackendSubscription) {
this.disposeBackendSubscription = this.backend.subscribeApp(() => {
void this.refresh();
});
}
void this.refresh();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.disposeBackendSubscription) {
this.disposeBackendSubscription();
this.disposeBackendSubscription = null;
}
};
}
@ -66,7 +75,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
async selectOrganization(organizationId: string): Promise<void> {
this.snapshot = await this.backend.selectAppOrganization(organizationId);
this.notify();
this.scheduleSyncPollingIfNeeded();
}
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
@ -77,7 +85,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
async triggerGithubSync(organizationId: string): Promise<void> {
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
this.notify();
this.scheduleSyncPollingIfNeeded();
}
async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void> {
@ -112,22 +119,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
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;
@ -137,7 +128,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
this.refreshPromise = (async () => {
this.snapshot = await this.backend.getAppSnapshot();
this.notify();
this.scheduleSyncPollingIfNeeded();
})().finally(() => {
this.refreshPromise = null;
});

View file

@ -51,6 +51,17 @@ function labelStyle(color: string) {
};
}
function mergedRouteParams(matches: Array<{ params: Record<string, unknown> }>): Record<string, string> {
return matches.reduce<Record<string, string>>((acc, match) => {
for (const [key, value] of Object.entries(match.params)) {
if (typeof value === "string" && value.length > 0) {
acc[key] = value;
}
}
return acc;
}, {});
}
export function DevPanel() {
if (!import.meta.env.DEV) {
return null;
@ -62,7 +73,12 @@ export function DevPanel() {
const user = activeMockUser(snapshot);
const organizations = eligibleOrganizations(snapshot);
const t = useFoundryTokens();
const location = useRouterState({ select: (state) => state.location });
const routeContext = useRouterState({
select: (state) => ({
location: state.location,
params: mergedRouteParams(state.matches as Array<{ params: Record<string, unknown> }>),
}),
});
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
useEffect(() => {
@ -84,8 +100,19 @@ export function DevPanel() {
}, []);
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
const github = organization?.github ?? null;
const runtime = organization?.runtime ?? null;
const selectedWorkspaceId = routeContext.params.workspaceId ?? null;
const selectedTaskId = routeContext.params.taskId ?? null;
const selectedRepoId = routeContext.params.repoId ?? null;
const selectedSessionId =
routeContext.location.search && typeof routeContext.location.search === "object" && "sessionId" in routeContext.location.search
? (((routeContext.location.search as Record<string, unknown>).sessionId as string | undefined) ?? null)
: null;
const contextOrganization =
(routeContext.params.organizationId ? (snapshot.organizations.find((candidate) => candidate.id === routeContext.params.organizationId) ?? null) : null) ??
(selectedWorkspaceId ? (snapshot.organizations.find((candidate) => candidate.workspaceId === selectedWorkspaceId) ?? null) : null) ??
organization;
const github = contextOrganization?.github ?? null;
const runtime = contextOrganization?.runtime ?? null;
const runtimeSummary = useMemo(() => {
if (!runtime || runtime.errorCount === 0) {
return "No actor errors";
@ -122,16 +149,31 @@ export function DevPanel() {
alignItems: "center",
gap: "8px",
border: `1px solid ${t.borderDefault}`,
background: t.surfacePrimary,
background: "rgba(9, 9, 11, 0.78)",
color: t.textPrimary,
borderRadius: "999px",
padding: "10px 14px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28)",
padding: "9px 12px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.22)",
cursor: "pointer",
}}
>
<Bug size={14} />
Dev
<span style={{ display: "inline-flex", alignItems: "center", gap: "8px", fontSize: "12px", lineHeight: 1 }}>
<span style={{ color: t.textSecondary }}>Show Dev Panel</span>
<span
style={{
padding: "4px 7px",
borderRadius: "999px",
border: `1px solid ${t.borderDefault}`,
background: "rgba(255, 255, 255, 0.04)",
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.03em",
}}
>
Shift+D
</span>
</span>
</button>
);
}
@ -181,7 +223,7 @@ export function DevPanel() {
{modeLabel}
</span>
</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>{location.pathname}</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>{routeContext.location.pathname}</div>
</div>
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
Hide
@ -189,12 +231,23 @@ export function DevPanel() {
</div>
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
<div style={labelStyle(t.textMuted)}>Context</div>
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
<div>Organization: {contextOrganization?.settings.displayName ?? "None selected"}</div>
<div>Workspace: {selectedWorkspaceId ?? "None selected"}</div>
<div>Task: {selectedTaskId ?? "None selected"}</div>
<div>Repo: {selectedRepoId ?? "None selected"}</div>
<div>Session: {selectedSessionId ?? "None selected"}</div>
</div>
</div>
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
<div style={labelStyle(t.textMuted)}>Session</div>
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
<div>Auth: {snapshot.auth.status}</div>
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</div>
<div>Organization: {organization?.settings.displayName ?? "None selected"}</div>
<div>Active org: {organization?.settings.displayName ?? "None selected"}</div>
</div>
{isMockFrontendClient ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
@ -221,26 +274,26 @@ export function DevPanel() {
<div>Repos: {github?.importedRepoCount ?? 0}</div>
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
</div>
{organization ? (
{contextOrganization ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={pillButtonStyle()}>
<button type="button" onClick={() => void client.triggerGithubSync(contextOrganization.id)} style={pillButtonStyle()}>
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
Sync
</button>
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={pillButtonStyle()}>
<button type="button" onClick={() => void client.reconnectGithub(contextOrganization.id)} style={pillButtonStyle()}>
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
Reconnect
</button>
</div>
) : null}
{isMockFrontendClient && organization && client.setMockDebugOrganizationState ? (
{isMockFrontendClient && contextOrganization && client.setMockDebugOrganizationState ? (
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubSyncStatus: status })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, githubSyncStatus: status })}
style={pillButtonStyle(github?.syncStatus === status)}
>
{status}
@ -252,7 +305,7 @@ export function DevPanel() {
<button
key={status}
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubInstallationStatus: status })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, githubInstallationStatus: status })}
style={pillButtonStyle(github?.installationStatus === status)}
>
{status}
@ -270,13 +323,13 @@ export function DevPanel() {
<div>{runtimeSummary}</div>
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
</div>
{organization ? (
{contextOrganization ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
<>
<button
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "error" })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, runtimeStatus: "error" })}
style={pillButtonStyle(runtime?.status === "error")}
>
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
@ -284,7 +337,7 @@ export function DevPanel() {
</button>
<button
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "healthy" })}
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: contextOrganization.id, runtimeStatus: "healthy" })}
style={pillButtonStyle(runtime?.status === "healthy")}
>
Healthy
@ -292,7 +345,7 @@ export function DevPanel() {
</>
) : null}
{runtime?.errorCount ? (
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(organization.id)} style={pillButtonStyle()}>
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(contextOrganization.id)} style={pillButtonStyle()}>
Clear actor errors
</button>
) : null}
@ -309,7 +362,7 @@ export function DevPanel() {
key={candidate.id}
type="button"
onClick={() => void client.selectOrganization(candidate.id)}
style={pillButtonStyle(organization?.id === candidate.id)}
style={pillButtonStyle(contextOrganization?.id === candidate.id)}
>
{candidate.settings.displayName}
</button>

View file

@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ChevronUp,
@ -14,6 +15,7 @@ import {
LogOut,
PanelLeft,
Plus,
RefreshCw,
Settings,
User,
} from "lucide-react";
@ -21,6 +23,7 @@ import {
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
import { getMockOrganizationStatus } from "../../lib/mock-organization-status";
import { useFoundryTokens } from "../../app/theme";
import type { FoundryTokens } from "../../styles/tokens";
@ -40,6 +43,28 @@ function projectIconColor(label: string): string {
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
}
function organizationStatusToneStyles(tokens: FoundryTokens, tone: "info" | "warning" | "error") {
if (tone === "error") {
return {
backgroundColor: "rgba(255, 79, 0, 0.14)",
borderColor: "rgba(255, 79, 0, 0.3)",
color: "#ffd6c7",
};
}
if (tone === "warning") {
return {
backgroundColor: "rgba(255, 193, 7, 0.16)",
borderColor: "rgba(255, 193, 7, 0.24)",
color: "#ffe6a6",
};
}
return {
backgroundColor: "rgba(24, 140, 255, 0.16)",
borderColor: "rgba(24, 140, 255, 0.24)",
color: "#b9d8ff",
};
}
export const Sidebar = memo(function Sidebar({
projects,
newTaskRepos,
@ -694,6 +719,7 @@ function SidebarFooter() {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const organization = activeMockOrganization(snapshot);
const organizationStatus = organization ? getMockOrganizationStatus(organization) : null;
const [open, setOpen] = useState(false);
const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@ -802,6 +828,41 @@ function SidebarFooter() {
gap: "2px",
});
const statusChipClass =
organizationStatus != null
? css({
display: "inline-flex",
alignItems: "center",
gap: "6px",
flexShrink: 0,
padding: "4px 7px",
borderRadius: "999px",
border: `1px solid ${organizationStatusToneStyles(t, organizationStatus.tone).borderColor}`,
backgroundColor: organizationStatusToneStyles(t, organizationStatus.tone).backgroundColor,
color: organizationStatusToneStyles(t, organizationStatus.tone).color,
fontSize: "10px",
fontWeight: 600,
lineHeight: 1,
})
: "";
const footerStatusClass =
organizationStatus != null
? css({
margin: "0 8px 4px",
padding: "8px 10px",
borderRadius: "10px",
border: `1px solid ${organizationStatusToneStyles(t, organizationStatus.tone).borderColor}`,
backgroundColor: organizationStatusToneStyles(t, organizationStatus.tone).backgroundColor,
color: organizationStatusToneStyles(t, organizationStatus.tone).color,
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "11px",
lineHeight: 1.3,
})
: "";
return (
<div ref={containerRef} className={css({ position: "relative", flexShrink: 0 })}>
{open ? (
@ -851,6 +912,7 @@ function SidebarFooter() {
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{organization.settings.displayName}
</span>
{organizationStatus ? <span className={statusChipClass}>{organizationStatus.label}</span> : null}
<ChevronRight size={12} className={css({ flexShrink: 0, color: t.textMuted })} />
</button>
</div>
@ -919,6 +981,30 @@ function SidebarFooter() {
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{org.settings.displayName}
</span>
{(() => {
const orgStatus = getMockOrganizationStatus(org);
if (!orgStatus) return null;
const tone = organizationStatusToneStyles(t, orgStatus.tone);
return (
<span
className={css({
display: "inline-flex",
alignItems: "center",
padding: "4px 7px",
borderRadius: "999px",
border: `1px solid ${tone.borderColor}`,
backgroundColor: tone.backgroundColor,
color: tone.color,
fontSize: "10px",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
})}
>
{orgStatus.label}
</span>
);
})()}
</button>
);
})}
@ -949,6 +1035,15 @@ function SidebarFooter() {
</div>
</div>
) : null}
{organizationStatus ? (
<div className={footerStatusClass}>
{organizationStatus.key === "syncing" ? <RefreshCw size={12} /> : <AlertTriangle size={12} />}
<div className={css({ display: "grid", gap: "2px", minWidth: 0 })}>
<div className={css({ fontWeight: 600 })}>{organizationStatus.label}</div>
<div className={css({ color: t.textSecondary, fontSize: "10px" })}>{organizationStatus.detail}</div>
</div>
</div>
) : null}
<div className={css({ padding: "8px" })}>
<button
type="button"

View file

@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users } from "lucide-react";
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { isMockFrontendClient } from "../lib/env";
import { getMockOrganizationStatus } from "../lib/mock-organization-status";
import { useColorMode, useFoundryTokens } from "../app/theme";
import type { FoundryTokens } from "../styles/tokens";
import { appSurfaceStyle, primaryButtonStyle, secondaryButtonStyle, subtleButtonStyle, cardStyle, badgeStyle, inputStyle } from "../styles/shared-styles";
@ -134,6 +135,40 @@ function githubBadge(t: FoundryTokens, organization: FoundryOrganization) {
return <span style={badgeStyle(t, t.borderSubtle)}>Install GitHub App</span>;
}
function organizationStatusBadge(t: FoundryTokens, organization: FoundryOrganization) {
const status = getMockOrganizationStatus(organization);
if (!status) {
return null;
}
const toneStyles =
status.tone === "error"
? { background: "rgba(255, 79, 0, 0.18)", color: "#ffd6c7", borderColor: "rgba(255, 79, 0, 0.35)" }
: status.tone === "warning"
? { background: "rgba(255, 193, 7, 0.18)", color: "#ffe6a6", borderColor: "rgba(255, 193, 7, 0.28)" }
: { background: "rgba(24, 140, 255, 0.18)", color: "#b9d8ff", borderColor: "rgba(24, 140, 255, 0.28)" };
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 8px",
borderRadius: "999px",
border: `1px solid ${toneStyles.borderColor}`,
background: toneStyles.background,
color: toneStyles.color,
fontSize: "11px",
fontWeight: 600,
lineHeight: 1,
}}
>
{status.label}
</span>
);
}
function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) {
const t = useFoundryTokens();
return (
@ -410,7 +445,10 @@ export function MockOrganizationSelectorPage() {
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "14px", fontWeight: 500, lineHeight: 1.3 }}>{organization.settings.displayName}</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<div style={{ fontSize: "14px", fontWeight: 500, lineHeight: 1.3 }}>{organization.settings.displayName}</div>
{organizationStatusBadge(t, organization)}
</div>
<div style={{ fontSize: "12px", color: t.textTertiary, lineHeight: 1.3, marginTop: "1px" }}>
{organization.kind === "personal" ? "Personal" : "Organization"} · {planCatalog[organization.billing.planId]!.label} ·{" "}
{organization.members.length} member{organization.members.length !== 1 ? "s" : ""}

View file

@ -0,0 +1,63 @@
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
export type MockOrganizationStatusTone = "info" | "warning" | "error";
export interface MockOrganizationStatus {
key: "syncing" | "pending" | "sync_error" | "reconnect_required" | "install_required";
label: string;
detail: string;
tone: MockOrganizationStatusTone;
}
export function getMockOrganizationStatus(organization: FoundryOrganization): MockOrganizationStatus | null {
if (organization.kind === "personal") {
return null;
}
if (organization.github.installationStatus === "reconnect_required") {
return {
key: "reconnect_required",
label: "Connection issue",
detail: "Reconnect GitHub",
tone: "error",
};
}
if (organization.github.installationStatus === "install_required") {
return {
key: "install_required",
label: "Link GitHub",
detail: "Install GitHub App",
tone: "warning",
};
}
if (organization.github.syncStatus === "syncing") {
return {
key: "syncing",
label: "Syncing",
detail: "Syncing repositories",
tone: "info",
};
}
if (organization.github.syncStatus === "pending") {
return {
key: "pending",
label: "Needs sync",
detail: "Waiting for first sync",
tone: "warning",
};
}
if (organization.github.syncStatus === "error") {
return {
key: "sync_error",
label: "Sync failed",
detail: "Last GitHub sync failed",
tone: "error",
};
}
return null;
}