fix(renderer): refresh runtime catalogs on remote repo events (#13787)

A runtime host emits one `reposChanged` client event for project-group and
folder-workspace mutations as well as repo mutations (createProjectGroup,
moveProjectToGroup, createFolderWorkspace, ... all call notifyReposChanged),
but the client's handler only refetched that host's repo catalog, worktrees,
and lineage. Group and folder-workspace rows for a remote runtime therefore
stayed stale for the rest of the session unless an unrelated *local*
`repos:changed` happened to fire the all-host sweep.

Refetch the environment's project group and folder workspace catalogs in the
same scheduled refresh. Groups are fetched first because folder workspaces
resolve their owning group from `projectGroups`. Both fetches are host-fenced
(`claimHostCatalogFence`), so they cannot clobber local or other runtimes'
rows, and the scheduler's existing debounce/min-interval still bounds them.
This commit is contained in:
Neil
2026-08-12 18:14:21 -07:00
committed by GitHub
parent a2c9f7e52b
commit c1e517db56
2 changed files with 111 additions and 0 deletions
+106
View File
@@ -10175,6 +10175,112 @@ describe('useIpcEvents agent status snapshot integration', () => {
})
})
describe('runtime host catalog refresh on reposChanged', () => {
// Why: the host emits one reposChanged for project-group and folder-workspace edits
// too, so a repos-only refresh leaves those catalogs stale on every paired client.
it('refetches the runtime host project group and folder workspace catalogs', async () => {
vi.resetModules()
vi.useFakeTimers()
try {
const calls: string[] = []
const fetchRuntimeEnvironmentRepos = vi.fn(() => {
calls.push('repos')
return Promise.resolve([])
})
const fetchProjectGroups = vi.fn(() => {
calls.push('project-groups')
return Promise.resolve()
})
const fetchFolderWorkspaces = vi.fn(() => {
calls.push('folder-workspaces')
return Promise.resolve()
})
const state = {
settings: { activeRuntimeEnvironmentId: 'env-1' as string | null },
repos: [],
worktreesByRepo: {},
folderWorkspaces: [],
projectGroups: [],
runtimeEnvironments: [],
runtimeStatusByEnvironmentId: new Map(),
tabsByWorktree: {},
ptyIdsByTabId: {},
remountTerminalTabForRecovery: vi.fn(),
markEnvironmentSshStateStale: vi.fn(),
fetchRepos: vi.fn(() => Promise.resolve()),
fetchRuntimeEnvironmentRepos,
fetchProjectGroups,
fetchFolderWorkspaces,
fetchWorktrees: vi.fn(() => Promise.resolve()),
fetchWorktreeLineage: vi.fn(() => Promise.resolve())
}
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() }
})
vi.doMock('../store', () => ({
useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => state }
}))
const noopListener = (): (() => void) => () => {}
const autoStubNamespace = new Proxy(
{},
{
get:
() =>
(...args: unknown[]) => {
if (typeof args[0] === 'function') {
return noopListener()
}
return new Promise(() => {})
}
}
)
let runtimeOnResponse: ((response: unknown) => void) | undefined
const api = new Proxy(
{
runtimeEnvironments: {
subscribe: async (_args: unknown, callbacks: { onResponse: (r: unknown) => void }) => {
runtimeOnResponse = callbacks.onResponse
return { unsubscribe: vi.fn(), sendBinary: vi.fn() }
}
}
} as Record<string, unknown>,
{ get: (target, prop: string) => target[prop] ?? autoStubNamespace }
)
vi.stubGlobal('window', { api })
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
// Seeded discovery for the connected runtime; drains the scheduler's debounce.
await vi.advanceTimersByTimeAsync(300)
const runtimeOwner = { runtimeEnvironmentId: 'env-1' }
expect(fetchProjectGroups).toHaveBeenCalledWith(runtimeOwner)
expect(fetchFolderWorkspaces).toHaveBeenCalledWith(runtimeOwner)
// Folder workspaces resolve their owning group from projectGroups, so groups must land first.
expect(calls).toEqual(['repos', 'project-groups', 'folder-workspaces'])
calls.length = 0
fetchProjectGroups.mockClear()
fetchFolderWorkspaces.mockClear()
if (!runtimeOnResponse) {
throw new Error('Expected runtime client event callbacks')
}
// Past the scheduler's min interval so the event schedules on the debounce alone.
await vi.advanceTimersByTimeAsync(5_000)
runtimeOnResponse({ ok: true, result: { type: 'reposChanged' } })
await vi.advanceTimersByTimeAsync(300)
expect(fetchProjectGroups).toHaveBeenCalledWith(runtimeOwner)
expect(fetchFolderWorkspaces).toHaveBeenCalledWith(runtimeOwner)
expect(calls).toEqual(['repos', 'project-groups', 'folder-workspaces'])
} finally {
vi.useRealTimers()
}
})
})
describe('parked terminal recovery on repos:changed', () => {
it('remounts a pane that parked on an unresolved host once repos hydrate', async () => {
vi.resetModules()
+5
View File
@@ -937,6 +937,11 @@ export function useIpcEvents(): void {
// Why: project events can reveal target CRUD, but known target states already arrive by push.
void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {})
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
// Why: the host emits one reposChanged for group/folder-workspace edits too, so those
// catalogs go stale without this; groups first because folder workspaces resolve owners from them.
const runtimeOwner = { runtimeEnvironmentId: environmentId }
await useAppStore.getState().fetchProjectGroups(runtimeOwner)
await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner)
await refreshRuntimeProjectWorktreesAndLineage(
environmentId,
repos,