mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 21:03:46 +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",
|
tag: "0000_github_state",
|
||||||
breakpoints: true,
|
breakpoints: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
idx: 1,
|
||||||
|
when: 1773340800000,
|
||||||
|
tag: "0001_github_state_sync_progress",
|
||||||
|
breakpoints: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -53,6 +59,12 @@ CREATE TABLE \`github_pull_requests\` (
|
||||||
\`is_draft\` integer NOT NULL,
|
\`is_draft\` integer NOT NULL,
|
||||||
\`updated_at\` 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,
|
} as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ export const githubMeta = sqliteTable("github_meta", {
|
||||||
installationId: integer("installation_id"),
|
installationId: integer("installation_id"),
|
||||||
lastSyncLabel: text("last_sync_label").notNull(),
|
lastSyncLabel: text("last_sync_label").notNull(),
|
||||||
lastSyncAt: integer("last_sync_at"),
|
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(),
|
updatedAt: integer("updated_at").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { eq } from "drizzle-orm";
|
import { randomUUID } from "node:crypto";
|
||||||
import { actor } from "rivetkit";
|
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 type { FoundryGithubInstallationStatus, FoundryGithubSyncStatus } from "@sandbox-agent/foundry-shared";
|
||||||
import { repoIdFromRemote } from "../../services/repo.js";
|
import { repoIdFromRemote } from "../../services/repo.js";
|
||||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||||
import { getActorRuntimeContext } from "../context.js";
|
import { getActorRuntimeContext } from "../context.js";
|
||||||
import { getOrCreateOrganization, getOrCreateRepository, selfGithubState } from "../handles.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 { githubStateDb } from "./db/db.js";
|
||||||
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
||||||
|
|
||||||
const META_ROW_ID = 1;
|
const META_ROW_ID = 1;
|
||||||
|
const GITHUB_PR_BATCH_SIZE = 10;
|
||||||
|
const GITHUB_QUEUE_NAMES = ["github.command.full_sync"] as const;
|
||||||
|
|
||||||
interface GithubStateInput {
|
interface GithubStateInput {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|
@ -22,6 +27,11 @@ interface GithubStateMeta {
|
||||||
installationId: number | null;
|
installationId: number | null;
|
||||||
lastSyncLabel: string;
|
lastSyncLabel: string;
|
||||||
lastSyncAt: number | null;
|
lastSyncAt: number | null;
|
||||||
|
syncPhase: string | null;
|
||||||
|
syncRunStartedAt: number | null;
|
||||||
|
syncRepositoriesTotal: number | null;
|
||||||
|
syncRepositoriesCompleted: number;
|
||||||
|
syncPullRequestRepositoriesCompleted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncMemberSeed {
|
interface SyncMemberSeed {
|
||||||
|
|
@ -42,6 +52,12 @@ interface FullSyncInput {
|
||||||
accessToken?: string | null;
|
accessToken?: string | null;
|
||||||
label?: string;
|
label?: string;
|
||||||
fallbackMembers?: SyncMemberSeed[];
|
fallbackMembers?: SyncMemberSeed[];
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FullSyncCommand extends FullSyncInput {
|
||||||
|
runId: string;
|
||||||
|
runStartedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PullRequestWebhookInput {
|
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" {
|
function normalizePullRequestStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "draft" | "ready" | "closed" | "merged" {
|
||||||
const rawState = input.state.trim().toUpperCase();
|
const rawState = input.state.trim().toUpperCase();
|
||||||
if (input.merged || rawState === "MERGED") {
|
if (input.merged || rawState === "MERGED") {
|
||||||
|
|
@ -78,24 +124,13 @@ function normalizePullRequestStatus(input: { state: string; isDraft?: boolean; m
|
||||||
return input.isDraft ? "draft" : "ready";
|
return input.isDraft ? "draft" : "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FullSyncSnapshot {
|
function repoBelongsToAccount(fullName: string, accountLogin: string): boolean {
|
||||||
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
|
const owner = fullName.split("/")[0]?.trim().toLowerCase() ?? "";
|
||||||
members: SyncMemberSeed[];
|
return owner.length > 0 && owner === accountLogin.trim().toLowerCase();
|
||||||
loadPullRequests: () => Promise<
|
}
|
||||||
Array<{
|
|
||||||
repoFullName: string;
|
function batchLabel(completed: number, total: number): string {
|
||||||
cloneUrl: string;
|
return `Syncing pull requests (${completed}/${total} repositories)...`;
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
body?: string | null;
|
|
||||||
state: string;
|
|
||||||
url: string;
|
|
||||||
headRefName: string;
|
|
||||||
baseRefName: string;
|
|
||||||
authorLogin?: string | null;
|
|
||||||
isDraft?: boolean;
|
|
||||||
}>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMeta(c: any): Promise<GithubStateMeta> {
|
async function readMeta(c: any): Promise<GithubStateMeta> {
|
||||||
|
|
@ -107,6 +142,11 @@ async function readMeta(c: any): Promise<GithubStateMeta> {
|
||||||
installationId: row?.installationId ?? null,
|
installationId: row?.installationId ?? null,
|
||||||
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first sync",
|
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first sync",
|
||||||
lastSyncAt: row?.lastSyncAt ?? null,
|
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,
|
installationId: next.installationId,
|
||||||
lastSyncLabel: next.lastSyncLabel,
|
lastSyncLabel: next.lastSyncLabel,
|
||||||
lastSyncAt: next.lastSyncAt,
|
lastSyncAt: next.lastSyncAt,
|
||||||
|
syncPhase: next.syncPhase,
|
||||||
|
syncRunStartedAt: next.syncRunStartedAt,
|
||||||
|
syncRepositoriesTotal: next.syncRepositoriesTotal,
|
||||||
|
syncRepositoriesCompleted: next.syncRepositoriesCompleted,
|
||||||
|
syncPullRequestRepositoriesCompleted: next.syncPullRequestRepositoriesCompleted,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
|
|
@ -137,6 +182,11 @@ async function writeMeta(c: any, patch: Partial<GithubStateMeta>): Promise<Githu
|
||||||
installationId: next.installationId,
|
installationId: next.installationId,
|
||||||
lastSyncLabel: next.lastSyncLabel,
|
lastSyncLabel: next.lastSyncLabel,
|
||||||
lastSyncAt: next.lastSyncAt,
|
lastSyncAt: next.lastSyncAt,
|
||||||
|
syncPhase: next.syncPhase,
|
||||||
|
syncRunStartedAt: next.syncRunStartedAt,
|
||||||
|
syncRepositoriesTotal: next.syncRepositoriesTotal,
|
||||||
|
syncRepositoriesCompleted: next.syncRepositoriesCompleted,
|
||||||
|
syncPullRequestRepositoriesCompleted: next.syncPullRequestRepositoriesCompleted,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -144,9 +194,17 @@ async function writeMeta(c: any, patch: Partial<GithubStateMeta>): Promise<Githu
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceRepositories(c: any, repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>): Promise<void> {
|
async function notifyAppUpdated(c: any): Promise<void> {
|
||||||
await c.db.delete(githubRepositories).run();
|
const app = await getOrCreateOrganization(c, APP_SHELL_ORGANIZATION_ID);
|
||||||
const now = Date.now();
|
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) {
|
for (const repository of repositories) {
|
||||||
await c.db
|
await c.db
|
||||||
.insert(githubRepositories)
|
.insert(githubRepositories)
|
||||||
|
|
@ -155,15 +213,22 @@ async function replaceRepositories(c: any, repositories: Array<{ fullName: strin
|
||||||
fullName: repository.fullName,
|
fullName: repository.fullName,
|
||||||
cloneUrl: repository.cloneUrl,
|
cloneUrl: repository.cloneUrl,
|
||||||
private: repository.private ? 1 : 0,
|
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();
|
.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceMembers(c: any, members: SyncMemberSeed[]): Promise<void> {
|
async function upsertMembers(c: any, members: SyncMemberSeed[], updatedAt: number): Promise<void> {
|
||||||
await c.db.delete(githubMembers).run();
|
|
||||||
const now = Date.now();
|
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
await c.db
|
await c.db
|
||||||
.insert(githubMembers)
|
.insert(githubMembers)
|
||||||
|
|
@ -174,30 +239,24 @@ async function replaceMembers(c: any, members: SyncMemberSeed[]): Promise<void>
|
||||||
email: member.email ?? null,
|
email: member.email ?? null,
|
||||||
role: member.role ?? null,
|
role: member.role ?? null,
|
||||||
state: member.state ?? "active",
|
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();
|
.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replacePullRequests(
|
async function upsertPullRequests(c: any, pullRequests: GitHubPullRequestSnapshot[], updatedAt: number): Promise<void> {
|
||||||
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();
|
|
||||||
for (const pullRequest of pullRequests) {
|
for (const pullRequest of pullRequests) {
|
||||||
const repoId = repoIdFromRemote(pullRequest.cloneUrl);
|
const repoId = repoIdFromRemote(pullRequest.cloneUrl);
|
||||||
await c.db
|
await c.db
|
||||||
|
|
@ -215,12 +274,34 @@ async function replacePullRequests(
|
||||||
baseRefName: pullRequest.baseRefName,
|
baseRefName: pullRequest.baseRefName,
|
||||||
authorLogin: pullRequest.authorLogin ?? null,
|
authorLogin: pullRequest.authorLogin ?? null,
|
||||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
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();
|
.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> {
|
async function upsertPullRequest(c: any, input: PullRequestWebhookInput): Promise<void> {
|
||||||
const repoId = repoIdFromRemote(input.repository.cloneUrl);
|
const repoId = repoIdFromRemote(input.repository.cloneUrl);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -340,13 +421,206 @@ async function countRows(c: any) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoBelongsToAccount(fullName: string, accountLogin: string): boolean {
|
async function resolveFullSyncSeed(c: any, input: FullSyncCommand): Promise<FullSyncSeed> {
|
||||||
const owner = fullName.split("/")[0]?.trim().toLowerCase() ?? "";
|
const { appShell } = getActorRuntimeContext();
|
||||||
return owner.length > 0 && owner === accountLogin.trim().toLowerCase();
|
|
||||||
|
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({
|
export const githubState = actor({
|
||||||
db: githubStateDb,
|
db: githubStateDb,
|
||||||
|
queues: Object.fromEntries(GITHUB_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||||
createState: (_c, input: GithubStateInput) => ({
|
createState: (_c, input: GithubStateInput) => ({
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
}),
|
}),
|
||||||
|
|
@ -423,111 +697,69 @@ export const githubState = actor({
|
||||||
syncStatus: input.installationStatus === "connected" ? "pending" : "error",
|
syncStatus: input.installationStatus === "connected" ? "pending" : "error",
|
||||||
lastSyncLabel: input.label,
|
lastSyncLabel: input.label,
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
|
syncPhase: null,
|
||||||
|
syncRunStartedAt: null,
|
||||||
|
syncRepositoriesTotal: null,
|
||||||
|
syncRepositoriesCompleted: 0,
|
||||||
|
syncPullRequestRepositoriesCompleted: 0,
|
||||||
});
|
});
|
||||||
const organization = await getOrCreateOrganization(c, c.state.organizationId);
|
const organization = await getOrCreateOrganization(c, c.state.organizationId);
|
||||||
await organization.applyOrganizationRepositoryCatalog({
|
await organization.applyOrganizationRepositoryCatalog({
|
||||||
repositories: [],
|
repositories: [],
|
||||||
});
|
});
|
||||||
|
await notifyOrganizationUpdated(c);
|
||||||
},
|
},
|
||||||
|
|
||||||
async fullSync(c, input: FullSyncInput) {
|
async fullSync(c, input: FullSyncInput) {
|
||||||
const { appShell } = getActorRuntimeContext();
|
const current = await readMeta(c);
|
||||||
const organization = await getOrCreateOrganization(c, c.state.organizationId);
|
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, {
|
await writeMeta(c, {
|
||||||
connectedAccount: input.connectedAccount,
|
connectedAccount: input.connectedAccount,
|
||||||
installationStatus: input.installationStatus,
|
installationStatus: input.installationStatus,
|
||||||
installationId: input.installationId,
|
installationId: input.installationId,
|
||||||
syncStatus: "syncing",
|
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 self = selfGithubState(c);
|
||||||
const syncFromUserToken = async (): Promise<FullSyncSnapshot> => {
|
await self.send(
|
||||||
const rawRepositories = input.accessToken ? await appShell.github.listUserRepositories(input.accessToken) : [];
|
githubWorkflowQueueName("github.command.full_sync"),
|
||||||
const repositories =
|
{
|
||||||
input.kind === "organization"
|
...input,
|
||||||
? rawRepositories.filter((repository) => repoBelongsToAccount(repository.fullName, input.githubLogin))
|
runId,
|
||||||
: rawRepositories;
|
runStartedAt,
|
||||||
const members =
|
} satisfies FullSyncCommand,
|
||||||
input.accessToken && input.kind === "organization"
|
{
|
||||||
? await appShell.github.listOrganizationMembers(input.accessToken, input.githubLogin)
|
wait: false,
|
||||||
: (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 { repositories, members, loadPullRequests } =
|
return await self.getSummary();
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async handlePullRequestWebhook(c, input: PullRequestWebhookInput): Promise<void> {
|
async handlePullRequestWebhook(c, input: PullRequestWebhookInput): Promise<void> {
|
||||||
|
|
@ -539,6 +771,8 @@ export const githubState = actor({
|
||||||
syncStatus: "synced",
|
syncStatus: "synced",
|
||||||
lastSyncLabel: `Updated PR #${input.pullRequest.number}`,
|
lastSyncLabel: `Updated PR #${input.pullRequest.number}`,
|
||||||
lastSyncAt: Date.now(),
|
lastSyncAt: Date.now(),
|
||||||
|
syncPhase: null,
|
||||||
|
syncRunStartedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const repository = await getOrCreateRepository(c, c.state.organizationId, repoIdFromRemote(input.repository.cloneUrl), input.repository.cloneUrl);
|
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,
|
branchName: input.pullRequest.headRefName,
|
||||||
state: input.pullRequest.state,
|
state: input.pullRequest.state,
|
||||||
});
|
});
|
||||||
|
await notifyOrganizationUpdated(c);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createPullRequest(
|
async createPullRequest(
|
||||||
|
|
@ -608,7 +843,10 @@ export const githubState = actor({
|
||||||
syncStatus: "synced",
|
syncStatus: "synced",
|
||||||
lastSyncLabel: `Linked existing PR #${existing.number}`,
|
lastSyncLabel: `Linked existing PR #${existing.number}`,
|
||||||
lastSyncAt: now,
|
lastSyncAt: now,
|
||||||
|
syncPhase: null,
|
||||||
|
syncRunStartedAt: null,
|
||||||
});
|
});
|
||||||
|
await notifyOrganizationUpdated(c);
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
@ -633,7 +871,10 @@ export const githubState = actor({
|
||||||
syncStatus: "synced",
|
syncStatus: "synced",
|
||||||
lastSyncLabel: `Created PR #${created.number}`,
|
lastSyncLabel: `Created PR #${created.number}`,
|
||||||
lastSyncAt: now,
|
lastSyncAt: now,
|
||||||
|
syncPhase: null,
|
||||||
|
syncRunStartedAt: null,
|
||||||
});
|
});
|
||||||
|
await notifyOrganizationUpdated(c);
|
||||||
|
|
||||||
return created;
|
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);
|
return await buildAppSnapshot(c, input.sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async notifyAppUpdated(c: any): Promise<void> {
|
||||||
|
assertAppWorkspace(c);
|
||||||
|
c.broadcast("appUpdated", { at: Date.now() });
|
||||||
|
},
|
||||||
|
|
||||||
async resolveAppGithubToken(
|
async resolveAppGithubToken(
|
||||||
c: any,
|
c: any,
|
||||||
input: { organizationId: string; requireRepoScope?: boolean },
|
input: { organizationId: string; requireRepoScope?: boolean },
|
||||||
|
|
@ -785,6 +790,7 @@ export const workspaceAppActions = {
|
||||||
installationId: organization.snapshot.kind === "personal" ? null : organization.githubInstallationId,
|
installationId: organization.snapshot.kind === "personal" ? null : organization.githubInstallationId,
|
||||||
accessToken: auth.accessToken,
|
accessToken: auth.accessToken,
|
||||||
label: "Syncing GitHub data...",
|
label: "Syncing GitHub data...",
|
||||||
|
force: true,
|
||||||
fallbackMembers:
|
fallbackMembers:
|
||||||
organization.snapshot.kind === "personal"
|
organization.snapshot.kind === "personal"
|
||||||
? [
|
? [
|
||||||
|
|
@ -1052,8 +1058,8 @@ export const workspaceAppActions = {
|
||||||
const { appShell } = getActorRuntimeContext();
|
const { appShell } = getActorRuntimeContext();
|
||||||
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
|
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
|
||||||
|
|
||||||
const accountLogin = body.installation?.account?.login;
|
const accountLogin = body.installation?.account?.login ?? body.repository?.owner?.login ?? body.organization?.login ?? null;
|
||||||
const accountType = body.installation?.account?.type;
|
const accountType = body.installation?.account?.type ?? body.repository?.owner?.type ?? (body.organization?.login ? "Organization" : null);
|
||||||
if (!accountLogin) {
|
if (!accountLogin) {
|
||||||
console.log(`[github-webhook] Ignoring ${event}.${body.action ?? ""}: no installation account`);
|
console.log(`[github-webhook] Ignoring ${event}.${body.action ?? ""}: no installation account`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -1080,6 +1086,7 @@ export const workspaceAppActions = {
|
||||||
installationStatus: "connected",
|
installationStatus: "connected",
|
||||||
installationId: body.installation?.id ?? null,
|
installationId: body.installation?.id ?? null,
|
||||||
label: "Syncing GitHub data from installation webhook...",
|
label: "Syncing GitHub data from installation webhook...",
|
||||||
|
force: true,
|
||||||
fallbackMembers: [],
|
fallbackMembers: [],
|
||||||
});
|
});
|
||||||
} else if (body.action === "suspend") {
|
} else if (body.action === "suspend") {
|
||||||
|
|
@ -1097,6 +1104,7 @@ export const workspaceAppActions = {
|
||||||
installationStatus: "connected",
|
installationStatus: "connected",
|
||||||
installationId: body.installation?.id ?? null,
|
installationId: body.installation?.id ?? null,
|
||||||
label: "Resyncing GitHub data after unsuspend...",
|
label: "Resyncing GitHub data after unsuspend...",
|
||||||
|
force: true,
|
||||||
fallbackMembers: [],
|
fallbackMembers: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1114,6 +1122,7 @@ export const workspaceAppActions = {
|
||||||
installationStatus: "connected",
|
installationStatus: "connected",
|
||||||
installationId: body.installation?.id ?? null,
|
installationId: body.installation?.id ?? null,
|
||||||
label: "Resyncing GitHub data after repository access change...",
|
label: "Resyncing GitHub data after repository access change...",
|
||||||
|
force: true,
|
||||||
fallbackMembers: [],
|
fallbackMembers: [],
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
||||||
const notifications = createNotificationService(backends);
|
const notifications = createNotificationService(backends);
|
||||||
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
|
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
|
||||||
|
|
||||||
registry.startRunner();
|
await registry.startRunner();
|
||||||
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
|
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
|
||||||
const actorClient = createClient({
|
const actorClient = createClient({
|
||||||
endpoint: managerOrigin,
|
endpoint: managerOrigin,
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,11 @@ export class GitHubAppClient {
|
||||||
return await this.listPullRequestsForRepositories(repositories, accessToken);
|
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[]> {
|
async listUserPullRequests(accessToken: string): Promise<GitHubPullRequestRecord[]> {
|
||||||
const repositories = await this.listUserRepositories(accessToken);
|
const repositories = await this.listUserRepositories(accessToken);
|
||||||
return await this.listPullRequestsForRepositories(repositories, accessToken);
|
return await this.listPullRequestsForRepositories(repositories, accessToken);
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ export interface BackendMetadata {
|
||||||
|
|
||||||
export interface BackendClient {
|
export interface BackendClient {
|
||||||
getAppSnapshot(): Promise<FoundryAppSnapshot>;
|
getAppSnapshot(): Promise<FoundryAppSnapshot>;
|
||||||
|
subscribeApp(listener: () => void): () => void;
|
||||||
signInWithGithub(): Promise<void>;
|
signInWithGithub(): Promise<void>;
|
||||||
signOutApp(): Promise<FoundryAppSnapshot>;
|
signOutApp(): Promise<FoundryAppSnapshot>;
|
||||||
skipAppStarterRepo(): Promise<FoundryAppSnapshot>;
|
skipAppStarterRepo(): Promise<FoundryAppSnapshot>;
|
||||||
|
|
@ -405,6 +406,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
||||||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
const appSubscriptions = {
|
||||||
|
listeners: new Set<() => void>(),
|
||||||
|
disposeConnPromise: null as Promise<(() => Promise<void>) | null> | null,
|
||||||
|
};
|
||||||
const sandboxProcessSubscriptions = new Map<
|
const sandboxProcessSubscriptions = new Map<
|
||||||
string,
|
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 sandboxProcessSubscriptionKey = (workspaceId: string, providerId: ProviderId, sandboxId: string): string => `${workspaceId}:${providerId}:${sandboxId}`;
|
||||||
|
|
||||||
const subscribeSandboxProcesses = (workspaceId: string, providerId: ProviderId, sandboxId: string, listener: () => void): (() => void) => {
|
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");
|
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
subscribeApp(listener: () => void): () => void {
|
||||||
|
return subscribeApp(listener);
|
||||||
|
},
|
||||||
|
|
||||||
async signInWithGithub(): Promise<void> {
|
async signInWithGithub(): Promise<void> {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`);
|
window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`);
|
||||||
|
|
|
||||||
|
|
@ -548,15 +548,11 @@ class MockFoundryAppStore implements MockFoundryAppClient {
|
||||||
|
|
||||||
async selectOrganization(organizationId: string): Promise<void> {
|
async selectOrganization(organizationId: string): Promise<void> {
|
||||||
await this.injectAsyncLatency();
|
await this.injectAsyncLatency();
|
||||||
const org = this.requireOrganization(organizationId);
|
this.requireOrganization(organizationId);
|
||||||
this.updateSnapshot((current) => ({
|
this.updateSnapshot((current) => ({
|
||||||
...current,
|
...current,
|
||||||
activeOrganizationId: organizationId,
|
activeOrganizationId: organizationId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (org.github.syncStatus !== "synced") {
|
|
||||||
await this.triggerGithubSync(organizationId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
|
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
||||||
return unsupportedAppSnapshot();
|
return unsupportedAppSnapshot();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
subscribeApp(): () => void {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
|
||||||
async signInWithGithub(): Promise<void> {
|
async signInWithGithub(): Promise<void> {
|
||||||
notSupported("signInWithGithub");
|
notSupported("signInWithGithub");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
||||||
};
|
};
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private refreshPromise: Promise<void> | null = null;
|
private refreshPromise: Promise<void> | null = null;
|
||||||
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
private disposeBackendSubscription: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(options: RemoteFoundryAppClientOptions) {
|
constructor(options: RemoteFoundryAppClientOptions) {
|
||||||
this.backend = options.backend;
|
this.backend = options.backend;
|
||||||
|
|
@ -37,9 +37,18 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
||||||
|
|
||||||
subscribe(listener: () => void): () => void {
|
subscribe(listener: () => void): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
|
if (!this.disposeBackendSubscription) {
|
||||||
|
this.disposeBackendSubscription = this.backend.subscribeApp(() => {
|
||||||
|
void this.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
void this.refresh();
|
void this.refresh();
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners.delete(listener);
|
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> {
|
async selectOrganization(organizationId: string): Promise<void> {
|
||||||
this.snapshot = await this.backend.selectAppOrganization(organizationId);
|
this.snapshot = await this.backend.selectAppOrganization(organizationId);
|
||||||
this.notify();
|
this.notify();
|
||||||
this.scheduleSyncPollingIfNeeded();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
|
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
|
||||||
|
|
@ -77,7 +85,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
||||||
async triggerGithubSync(organizationId: string): Promise<void> {
|
async triggerGithubSync(organizationId: string): Promise<void> {
|
||||||
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
|
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
|
||||||
this.notify();
|
this.notify();
|
||||||
this.scheduleSyncPollingIfNeeded();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void> {
|
async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void> {
|
||||||
|
|
@ -112,22 +119,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
||||||
this.notify();
|
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> {
|
private async refresh(): Promise<void> {
|
||||||
if (this.refreshPromise) {
|
if (this.refreshPromise) {
|
||||||
await this.refreshPromise;
|
await this.refreshPromise;
|
||||||
|
|
@ -137,7 +128,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
||||||
this.refreshPromise = (async () => {
|
this.refreshPromise = (async () => {
|
||||||
this.snapshot = await this.backend.getAppSnapshot();
|
this.snapshot = await this.backend.getAppSnapshot();
|
||||||
this.notify();
|
this.notify();
|
||||||
this.scheduleSyncPollingIfNeeded();
|
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
this.refreshPromise = null;
|
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() {
|
export function DevPanel() {
|
||||||
if (!import.meta.env.DEV) {
|
if (!import.meta.env.DEV) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -62,7 +73,12 @@ export function DevPanel() {
|
||||||
const user = activeMockUser(snapshot);
|
const user = activeMockUser(snapshot);
|
||||||
const organizations = eligibleOrganizations(snapshot);
|
const organizations = eligibleOrganizations(snapshot);
|
||||||
const t = useFoundryTokens();
|
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());
|
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -84,8 +100,19 @@ export function DevPanel() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
|
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
|
||||||
const github = organization?.github ?? null;
|
const selectedWorkspaceId = routeContext.params.workspaceId ?? null;
|
||||||
const runtime = organization?.runtime ?? 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(() => {
|
const runtimeSummary = useMemo(() => {
|
||||||
if (!runtime || runtime.errorCount === 0) {
|
if (!runtime || runtime.errorCount === 0) {
|
||||||
return "No actor errors";
|
return "No actor errors";
|
||||||
|
|
@ -122,16 +149,31 @@ export function DevPanel() {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
border: `1px solid ${t.borderDefault}`,
|
border: `1px solid ${t.borderDefault}`,
|
||||||
background: t.surfacePrimary,
|
background: "rgba(9, 9, 11, 0.78)",
|
||||||
color: t.textPrimary,
|
color: t.textPrimary,
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
padding: "10px 14px",
|
padding: "9px 12px",
|
||||||
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28)",
|
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.22)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bug size={14} />
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +223,7 @@ export function DevPanel() {
|
||||||
{modeLabel}
|
{modeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "11px", color: t.textMuted }}>{location.pathname}</div>
|
<div style={{ fontSize: "11px", color: t.textMuted }}>{routeContext.location.pathname}</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
|
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
|
||||||
Hide
|
Hide
|
||||||
|
|
@ -189,12 +231,23 @@ export function DevPanel() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
|
<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={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||||
<div style={labelStyle(t.textMuted)}>Session</div>
|
<div style={labelStyle(t.textMuted)}>Session</div>
|
||||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||||
<div>Auth: {snapshot.auth.status}</div>
|
<div>Auth: {snapshot.auth.status}</div>
|
||||||
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</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>
|
</div>
|
||||||
{isMockFrontendClient ? (
|
{isMockFrontendClient ? (
|
||||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
|
@ -221,26 +274,26 @@ export function DevPanel() {
|
||||||
<div>Repos: {github?.importedRepoCount ?? 0}</div>
|
<div>Repos: {github?.importedRepoCount ?? 0}</div>
|
||||||
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
|
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
|
||||||
</div>
|
</div>
|
||||||
{organization ? (
|
{contextOrganization ? (
|
||||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
<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" }} />
|
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||||
Sync
|
Sync
|
||||||
</button>
|
</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" }} />
|
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||||
Reconnect
|
Reconnect
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isMockFrontendClient && organization && client.setMockDebugOrganizationState ? (
|
{isMockFrontendClient && contextOrganization && client.setMockDebugOrganizationState ? (
|
||||||
<div style={{ display: "grid", gap: "8px" }}>
|
<div style={{ display: "grid", gap: "8px" }}>
|
||||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||||
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
|
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
type="button"
|
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)}
|
style={pillButtonStyle(github?.syncStatus === status)}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
|
|
@ -252,7 +305,7 @@ export function DevPanel() {
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
type="button"
|
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)}
|
style={pillButtonStyle(github?.installationStatus === status)}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
|
|
@ -270,13 +323,13 @@ export function DevPanel() {
|
||||||
<div>{runtimeSummary}</div>
|
<div>{runtimeSummary}</div>
|
||||||
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
|
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
{organization ? (
|
{contextOrganization ? (
|
||||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
|
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="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")}
|
style={pillButtonStyle(runtime?.status === "error")}
|
||||||
>
|
>
|
||||||
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||||
|
|
@ -284,7 +337,7 @@ export function DevPanel() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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")}
|
style={pillButtonStyle(runtime?.status === "healthy")}
|
||||||
>
|
>
|
||||||
Healthy
|
Healthy
|
||||||
|
|
@ -292,7 +345,7 @@ export function DevPanel() {
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{runtime?.errorCount ? (
|
{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
|
Clear actor errors
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -309,7 +362,7 @@ export function DevPanel() {
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void client.selectOrganization(candidate.id)}
|
onClick={() => void client.selectOrganization(candidate.id)}
|
||||||
style={pillButtonStyle(organization?.id === candidate.id)}
|
style={pillButtonStyle(contextOrganization?.id === candidate.id)}
|
||||||
>
|
>
|
||||||
{candidate.settings.displayName}
|
{candidate.settings.displayName}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useStyletron } from "baseui";
|
import { useStyletron } from "baseui";
|
||||||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -21,6 +23,7 @@ import {
|
||||||
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
|
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
|
||||||
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||||
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
|
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
|
||||||
|
import { getMockOrganizationStatus } from "../../lib/mock-organization-status";
|
||||||
import { useFoundryTokens } from "../../app/theme";
|
import { useFoundryTokens } from "../../app/theme";
|
||||||
import type { FoundryTokens } from "../../styles/tokens";
|
import type { FoundryTokens } from "../../styles/tokens";
|
||||||
|
|
||||||
|
|
@ -40,6 +43,28 @@ function projectIconColor(label: string): string {
|
||||||
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
|
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({
|
export const Sidebar = memo(function Sidebar({
|
||||||
projects,
|
projects,
|
||||||
newTaskRepos,
|
newTaskRepos,
|
||||||
|
|
@ -694,6 +719,7 @@ function SidebarFooter() {
|
||||||
const client = useMockAppClient();
|
const client = useMockAppClient();
|
||||||
const snapshot = useMockAppSnapshot();
|
const snapshot = useMockAppSnapshot();
|
||||||
const organization = activeMockOrganization(snapshot);
|
const organization = activeMockOrganization(snapshot);
|
||||||
|
const organizationStatus = organization ? getMockOrganizationStatus(organization) : null;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false);
|
const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -802,6 +828,41 @@ function SidebarFooter() {
|
||||||
gap: "2px",
|
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 (
|
return (
|
||||||
<div ref={containerRef} className={css({ position: "relative", flexShrink: 0 })}>
|
<div ref={containerRef} className={css({ position: "relative", flexShrink: 0 })}>
|
||||||
{open ? (
|
{open ? (
|
||||||
|
|
@ -851,6 +912,7 @@ function SidebarFooter() {
|
||||||
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||||
{organization.settings.displayName}
|
{organization.settings.displayName}
|
||||||
</span>
|
</span>
|
||||||
|
{organizationStatus ? <span className={statusChipClass}>{organizationStatus.label}</span> : null}
|
||||||
<ChevronRight size={12} className={css({ flexShrink: 0, color: t.textMuted })} />
|
<ChevronRight size={12} className={css({ flexShrink: 0, color: t.textMuted })} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -919,6 +981,30 @@ function SidebarFooter() {
|
||||||
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||||
{org.settings.displayName}
|
{org.settings.displayName}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -949,6 +1035,15 @@ function SidebarFooter() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" })}>
|
<div className={css({ padding: "8px" })}>
|
||||||
<button
|
<button
|
||||||
type="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 { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users } from "lucide-react";
|
||||||
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||||
import { isMockFrontendClient } from "../lib/env";
|
import { isMockFrontendClient } from "../lib/env";
|
||||||
|
import { getMockOrganizationStatus } from "../lib/mock-organization-status";
|
||||||
import { useColorMode, useFoundryTokens } from "../app/theme";
|
import { useColorMode, useFoundryTokens } from "../app/theme";
|
||||||
import type { FoundryTokens } from "../styles/tokens";
|
import type { FoundryTokens } from "../styles/tokens";
|
||||||
import { appSurfaceStyle, primaryButtonStyle, secondaryButtonStyle, subtleButtonStyle, cardStyle, badgeStyle, inputStyle } from "../styles/shared-styles";
|
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>;
|
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 }) {
|
function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) {
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
return (
|
return (
|
||||||
|
|
@ -410,7 +445,10 @@ export function MockOrganizationSelectorPage() {
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<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" }}>
|
<div style={{ fontSize: "12px", color: t.textTertiary, lineHeight: 1.3, marginTop: "1px" }}>
|
||||||
{organization.kind === "personal" ? "Personal" : "Organization"} · {planCatalog[organization.billing.planId]!.label} ·{" "}
|
{organization.kind === "personal" ? "Personal" : "Organization"} · {planCatalog[organization.billing.planId]!.label} ·{" "}
|
||||||
{organization.members.length} member{organization.members.length !== 1 ? "s" : ""}
|
{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