mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 17:01:02 +00:00
Finalize Foundry sync flow
This commit is contained in:
parent
5c70cbcd23
commit
1c852cc5f8
14 changed files with 768 additions and 187 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -225,6 +225,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
subscribeApp(): () => void {
|
||||
return () => {};
|
||||
},
|
||||
|
||||
async signInWithGithub(): Promise<void> {
|
||||
notSupported("signInWithGithub");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" : ""}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue