diff --git a/foundry/packages/backend/src/actors/github-state/db/migrations.ts b/foundry/packages/backend/src/actors/github-state/db/migrations.ts index 958461a..cd76421 100644 --- a/foundry/packages/backend/src/actors/github-state/db/migrations.ts +++ b/foundry/packages/backend/src/actors/github-state/db/migrations.ts @@ -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, }; diff --git a/foundry/packages/backend/src/actors/github-state/db/schema.ts b/foundry/packages/backend/src/actors/github-state/db/schema.ts index 9527fc6..1ab95f2 100644 --- a/foundry/packages/backend/src/actors/github-state/db/schema.ts +++ b/foundry/packages/backend/src/actors/github-state/db/schema.ts @@ -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(), }); diff --git a/foundry/packages/backend/src/actors/github-state/index.ts b/foundry/packages/backend/src/actors/github-state/index.ts index 1693d39..f66411f 100644 --- a/foundry/packages/backend/src/actors/github-state/index.ts +++ b/foundry/packages/backend/src/actors/github-state/index.ts @@ -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 { @@ -107,6 +142,11 @@ async function readMeta(c: any): Promise { 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): Promise): Promise): Promise): Promise { - await c.db.delete(githubRepositories).run(); - const now = Date.now(); +async function notifyAppUpdated(c: any): Promise { + const app = await getOrCreateOrganization(c, APP_SHELL_ORGANIZATION_ID); + await app.notifyAppUpdated({}); +} + +async function notifyOrganizationUpdated(c: any): Promise { + 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 { 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 { - await c.db.delete(githubMembers).run(); - const now = Date.now(); +async function upsertMembers(c: any, members: SyncMemberSeed[], updatedAt: number): Promise { for (const member of members) { await c.db .insert(githubMembers) @@ -174,30 +239,24 @@ async function replaceMembers(c: any, members: SyncMemberSeed[]): Promise 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 { - await c.db.delete(githubPullRequests).run(); - const now = Date.now(); +async function upsertPullRequests(c: any, pullRequests: GitHubPullRequestSnapshot[], updatedAt: number): Promise { 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 { + 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 { 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 { + const { appShell } = getActorRuntimeContext(); + + const syncFromUserToken = async (): Promise => { + 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 { + const body = msg.body as FullSyncCommand; + const stepPrefix = `github-sync-${body.runId}`; + let completionSummary: Awaited> & Awaited>; + + 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 { + 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 => { - 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 => { - 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 { @@ -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), }); diff --git a/foundry/packages/backend/src/actors/organization/app-shell.ts b/foundry/packages/backend/src/actors/organization/app-shell.ts index 21bd349..ae50d66 100644 --- a/foundry/packages/backend/src/actors/organization/app-shell.ts +++ b/foundry/packages/backend/src/actors/organization/app-shell.ts @@ -610,6 +610,11 @@ export const workspaceAppActions = { return await buildAppSnapshot(c, input.sessionId); }, + async notifyAppUpdated(c: any): Promise { + 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 }; diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 05bb4b5..26dcec9 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -73,7 +73,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + const accessToken = await this.createInstallationAccessToken(installationId); + return await this.listPullRequestsForRepositories(repositories, accessToken); + } + async listUserPullRequests(accessToken: string): Promise { const repositories = await this.listUserRepositories(accessToken); return await this.listPullRequestsForRepositories(repositories, accessToken); diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 5de8c28..b8d68f6 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -156,6 +156,7 @@ export interface BackendMetadata { export interface BackendClient { getAppSnapshot(): Promise; + subscribeApp(listener: () => void): () => void; signInWithGithub(): Promise; signOutApp(): Promise; skipAppStarterRepo(): Promise; @@ -405,6 +406,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien disposeConnPromise: Promise<(() => Promise) | null> | null; } >(); + const appSubscriptions = { + listeners: new Set<() => void>(), + disposeConnPromise: null as Promise<(() => Promise) | 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) | 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("/app/snapshot"); }, + subscribeApp(listener: () => void): () => void { + return subscribeApp(listener); + }, + async signInWithGithub(): Promise { if (typeof window !== "undefined") { window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`); diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index 070d07c..3866163 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -548,15 +548,11 @@ class MockFoundryAppStore implements MockFoundryAppClient { async selectOrganization(organizationId: string): Promise { 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 { diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index acc92fd..be27c1b 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -225,6 +225,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return unsupportedAppSnapshot(); }, + subscribeApp(): () => void { + return () => {}; + }, + async signInWithGithub(): Promise { notSupported("signInWithGithub"); }, diff --git a/foundry/packages/client/src/remote/app-client.ts b/foundry/packages/client/src/remote/app-client.ts index 67830bb..e73dce6 100644 --- a/foundry/packages/client/src/remote/app-client.ts +++ b/foundry/packages/client/src/remote/app-client.ts @@ -25,7 +25,7 @@ class RemoteFoundryAppStore implements FoundryAppClient { }; private readonly listeners = new Set<() => void>(); private refreshPromise: Promise | null = null; - private syncPollTimeout: ReturnType | 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 { this.snapshot = await this.backend.selectAppOrganization(organizationId); this.notify(); - this.scheduleSyncPollingIfNeeded(); } async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { @@ -77,7 +85,6 @@ class RemoteFoundryAppStore implements FoundryAppClient { async triggerGithubSync(organizationId: string): Promise { this.snapshot = await this.backend.triggerAppRepoImport(organizationId); this.notify(); - this.scheduleSyncPollingIfNeeded(); } async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise { @@ -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 { 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; }); diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx index 1d970b6..b222d23 100644 --- a/foundry/packages/frontend/src/components/dev-panel.tsx +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -51,6 +51,17 @@ function labelStyle(color: string) { }; } +function mergedRouteParams(matches: Array<{ params: Record }>): Record { + return matches.reduce>((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 }>), + }), + }); const [visible, setVisible] = useState(() => 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).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", }} > - Dev + + Show Dev Panel + + Shift+D + + ); } @@ -181,7 +223,7 @@ export function DevPanel() { {modeLabel} -
{location.pathname}
+
{routeContext.location.pathname}
- ) : null} - {isMockFrontendClient && organization && client.setMockDebugOrganizationState ? ( + {isMockFrontendClient && contextOrganization && client.setMockDebugOrganizationState ? (
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
- {organization ? ( + {contextOrganization ? (
{isMockFrontendClient && client.setMockDebugOrganizationState ? ( <> ) : 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} diff --git a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx index d96abd3..9fb50e9 100644 --- a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx @@ -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(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 (
{open ? ( @@ -851,6 +912,7 @@ function SidebarFooter() { {organization.settings.displayName} + {organizationStatus ? {organizationStatus.label} : null}
@@ -919,6 +981,30 @@ function SidebarFooter() { {org.settings.displayName} + {(() => { + const orgStatus = getMockOrganizationStatus(org); + if (!orgStatus) return null; + const tone = organizationStatusToneStyles(t, orgStatus.tone); + return ( + + {orgStatus.label} + + ); + })()} ); })} @@ -949,6 +1035,15 @@ function SidebarFooter() {
) : null} + {organizationStatus ? ( +
+ {organizationStatus.key === "syncing" ? : } +
+
{organizationStatus.label}
+
{organizationStatus.detail}
+
+
+ ) : null}