Finalize Foundry sync flow

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

View file

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

View file

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

View file

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

View file

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