mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user